jbrowse-plugin-msaview 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +7 -0
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +20 -12
  3. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.js +5 -3
  5. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +24 -4
  6. package/dist/LaunchMsaView/components/BlastQuery/consts.js +44 -2
  7. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +1 -1
  8. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -0
  9. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +3 -1
  10. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +2 -1
  11. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +8 -3
  12. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +4 -1
  13. package/dist/LaunchMsaView/components/QueryRowSelector.js +21 -5
  14. package/dist/LaunchMsaView/detectQueryRow.d.ts +11 -0
  15. package/dist/LaunchMsaView/detectQueryRow.js +11 -4
  16. package/dist/LaunchMsaView/detectQueryRow.test.js +30 -0
  17. package/dist/LaunchMsaView/useQueryRowName.d.ts +1 -0
  18. package/dist/LaunchMsaView/useQueryRowName.js +5 -1
  19. package/dist/LaunchMsaView/useQueryRowName.test.js +23 -0
  20. package/dist/LaunchMsaViewExtensionPoint/index.js +8 -6
  21. package/dist/LaunchMsaViewExtensionPoint/index.test.js +21 -0
  22. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +3 -2
  23. package/dist/MsaViewPanel/afterCreateAutoruns.js +48 -19
  24. package/dist/MsaViewPanel/components/LaunchProgress.js +12 -3
  25. package/dist/MsaViewPanel/components/MsaViewPanel.js +6 -2
  26. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +23 -2
  27. package/dist/MsaViewPanel/doLaunchBlast.d.ts +8 -0
  28. package/dist/MsaViewPanel/doLaunchBlast.js +78 -93
  29. package/dist/MsaViewPanel/doLaunchBlast.test.d.ts +1 -0
  30. package/dist/MsaViewPanel/doLaunchBlast.test.js +158 -0
  31. package/dist/MsaViewPanel/doLaunchOrthologs.js +40 -5
  32. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +10 -0
  33. package/dist/MsaViewPanel/genomeToMSA.js +4 -4
  34. package/dist/MsaViewPanel/genomeToMSA.test.js +67 -0
  35. package/dist/MsaViewPanel/loadProteinDomains.d.ts +8 -2
  36. package/dist/MsaViewPanel/loadProteinDomains.js +18 -9
  37. package/dist/MsaViewPanel/loadProteinDomains.test.d.ts +1 -0
  38. package/dist/MsaViewPanel/loadProteinDomains.test.js +41 -0
  39. package/dist/MsaViewPanel/model.d.ts +160 -89
  40. package/dist/MsaViewPanel/model.js +125 -48
  41. package/dist/MsaViewPanel/model.test.d.ts +1 -0
  42. package/dist/MsaViewPanel/model.test.js +100 -0
  43. package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +12 -1
  44. package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +8 -8
  45. package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +133 -32
  46. package/dist/MsaViewPanel/msaDataStore.d.ts +2 -0
  47. package/dist/MsaViewPanel/msaDataStore.js +10 -0
  48. package/dist/MsaViewPanel/observeProteinHighlights.test.js +20 -3
  49. package/dist/MsaViewPanel/processInit.test.d.ts +1 -0
  50. package/dist/MsaViewPanel/processInit.test.js +72 -0
  51. package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +9 -0
  52. package/dist/MsaViewPanel/resolveConnectedTranscript.js +122 -0
  53. package/dist/MsaViewPanel/runLaunch.d.ts +1 -0
  54. package/dist/MsaViewPanel/runLaunch.js +35 -0
  55. package/dist/MsaViewPanel/runLaunch.test.js +46 -0
  56. package/dist/MsaViewPanel/structureConnection.d.ts +0 -4
  57. package/dist/MsaViewPanel/structureConnection.js +0 -19
  58. package/dist/MsaViewPanel/util.d.ts +39 -0
  59. package/dist/MsaViewPanel/util.js +44 -0
  60. package/dist/jbrowse-plugin-msaview.umd.production.min.js +44 -43
  61. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  62. package/dist/utils/browserAlign.d.ts +42 -0
  63. package/dist/utils/browserAlign.js +297 -0
  64. package/dist/utils/browserAlign.test.d.ts +1 -0
  65. package/dist/utils/browserAlign.test.js +85 -0
  66. package/dist/utils/ebiBlast.d.ts +10 -1
  67. package/dist/utils/ebiBlast.js +27 -1
  68. package/dist/utils/homologSearch.d.ts +31 -0
  69. package/dist/utils/homologSearch.js +6 -0
  70. package/dist/utils/msa.d.ts +3 -11
  71. package/dist/utils/msa.js +14 -29
  72. package/dist/utils/msaRows.d.ts +11 -8
  73. package/dist/utils/msaRows.js +14 -12
  74. package/dist/utils/phmmer.d.ts +11 -1
  75. package/dist/utils/phmmer.js +49 -12
  76. package/dist/utils/unirefHomologs.d.ts +94 -0
  77. package/dist/utils/unirefHomologs.js +193 -0
  78. package/dist/utils/unirefHomologs.test.d.ts +1 -0
  79. package/dist/utils/unirefHomologs.test.js +118 -0
  80. package/dist/version.d.ts +1 -1
  81. package/dist/version.js +1 -1
  82. package/package.json +5 -4
  83. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +38 -13
  84. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
  85. package/src/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.tsx +7 -2
  86. package/src/LaunchMsaView/components/BlastQuery/consts.ts +50 -2
  87. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
  88. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +4 -0
  89. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +10 -3
  90. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +4 -1
  91. package/src/LaunchMsaView/components/QueryRowSelector.tsx +35 -7
  92. package/src/LaunchMsaView/detectQueryRow.test.ts +34 -0
  93. package/src/LaunchMsaView/detectQueryRow.ts +22 -4
  94. package/src/LaunchMsaView/useQueryRowName.test.ts +27 -0
  95. package/src/LaunchMsaView/useQueryRowName.ts +5 -1
  96. package/src/LaunchMsaViewExtensionPoint/index.test.ts +23 -0
  97. package/src/LaunchMsaViewExtensionPoint/index.ts +32 -6
  98. package/src/MsaViewPanel/afterCreateAutoruns.ts +53 -18
  99. package/src/MsaViewPanel/components/LaunchProgress.tsx +26 -2
  100. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +24 -2
  101. package/src/MsaViewPanel/components/MsaViewPanel.tsx +8 -2
  102. package/src/MsaViewPanel/doLaunchBlast.test.ts +207 -0
  103. package/src/MsaViewPanel/doLaunchBlast.ts +92 -142
  104. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +13 -0
  105. package/src/MsaViewPanel/doLaunchOrthologs.ts +55 -5
  106. package/src/MsaViewPanel/genomeToMSA.test.ts +76 -0
  107. package/src/MsaViewPanel/genomeToMSA.ts +8 -4
  108. package/src/MsaViewPanel/loadProteinDomains.test.ts +52 -0
  109. package/src/MsaViewPanel/loadProteinDomains.ts +24 -11
  110. package/src/MsaViewPanel/model.test.ts +121 -0
  111. package/src/MsaViewPanel/model.ts +153 -55
  112. package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +152 -32
  113. package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +21 -11
  114. package/src/MsaViewPanel/msaDataStore.ts +10 -0
  115. package/src/MsaViewPanel/observeProteinHighlights.test.ts +27 -3
  116. package/src/MsaViewPanel/processInit.test.ts +84 -0
  117. package/src/MsaViewPanel/resolveConnectedTranscript.ts +150 -0
  118. package/src/MsaViewPanel/runLaunch.test.ts +54 -0
  119. package/src/MsaViewPanel/runLaunch.ts +39 -0
  120. package/src/MsaViewPanel/structureConnection.ts +0 -26
  121. package/src/MsaViewPanel/util.ts +75 -0
  122. package/src/utils/browserAlign.test.ts +102 -0
  123. package/src/utils/browserAlign.ts +352 -0
  124. package/src/utils/ebiBlast.ts +34 -0
  125. package/src/utils/homologSearch.ts +51 -0
  126. package/src/utils/msa.ts +19 -41
  127. package/src/utils/msaRows.ts +20 -16
  128. package/src/utils/phmmer.ts +57 -11
  129. package/src/utils/unirefHomologs.test.ts +149 -0
  130. package/src/utils/unirefHomologs.ts +323 -0
  131. package/src/version.ts +1 -1
  132. package/dist/MsaViewPanel/structureConnection.test.js +0 -53
  133. package/src/MsaViewPanel/structureConnection.test.ts +0 -62
  134. /package/dist/{MsaViewPanel/structureConnection.test.d.ts → LaunchMsaView/useQueryRowName.test.d.ts} +0 -0
@@ -8,7 +8,7 @@ import { loadProteinDomains } from './loadProteinDomains';
8
8
  import { cleanupOldData, generateDataStoreId, retrieveMsaData, storeMsaData, } from './msaDataStore';
9
9
  import { runLaunch } from './runLaunch';
10
10
  import { getProteinViews } from './structureConnection';
11
- import { getUniprotIdFromAlphaFoldUrl, hasQueryRow } from './util';
11
+ import { getUniprotIdFromAlphaFoldUrl, hasQueryRow, transcriptPosToVisibleCol, } from './util';
12
12
  const EXPIRED_MESSAGE = "This view's alignment is no longer in browser storage. Stored alignments are kept for 7 days after they were last used, and are lost when site data is cleared. Relaunch the alignment to rebuild it.";
13
13
  export function loadStoredData(self) {
14
14
  const { dataStoreId, rows } = self;
@@ -73,16 +73,20 @@ function sameData(a, b) {
73
73
  * it is recorded whether or not the write succeeded, so a browser refusing
74
74
  * IndexedDB (private mode) fails once rather than in a loop.
75
75
  *
76
- * A view whose data comes from a filehandle stores nothing at all: the file is
77
- * the source of truth and react-msaview refetches it at startup.
76
+ * A view whose data comes from a filehandle -- or from the indexed block its
77
+ * kept `init` names -- stores nothing at all: the file is the source of truth
78
+ * and it is refetched at startup.
78
79
  */
79
80
  export function storeDataToIndexedDB(self) {
80
- const { rows, dataStoreId, isStoringData, lastStoredData } = self;
81
+ const { rows, dataStoreId, isStoringData, lastStoredData, init } = self;
81
82
  const data = currentData(self);
82
83
  if (rows.length === 0 ||
83
84
  isStoringData ||
84
85
  self.msaFilehandle ||
85
86
  self.treeFilehandle ||
87
+ // an indexed view keeps its init and refetches the block, so a row here
88
+ // would be one nothing ever reads
89
+ !!init?.msaIndexedLocation ||
86
90
  !(data.msa || data.tree) ||
87
91
  sameData(lastStoredData, data)) {
88
92
  return;
@@ -117,7 +121,7 @@ export function storeDataToIndexedDB(self) {
117
121
  * until a new request replaces them.
118
122
  */
119
123
  export function launchOrthologsIfNeeded(self) {
120
- if (self.orthologParams) {
124
+ if (self.orthologParams && !awaitingTranscript(self)) {
121
125
  runLaunch({
122
126
  self,
123
127
  message: 'Resolving orthologs',
@@ -128,8 +132,17 @@ export function launchOrthologsIfNeeded(self) {
128
132
  });
129
133
  }
130
134
  }
135
+ /**
136
+ * A launch that names its transcript rather than its feature has to wait for
137
+ * the lookup: the query row is that transcript's translation, and the launch
138
+ * cannot start without a query. Both launchers read the same two fields, so
139
+ * the resolver setting `connectedFeature` is what refires them.
140
+ */
141
+ function awaitingTranscript(self) {
142
+ return !!self.connectedTranscript && !self.connectedFeature;
143
+ }
131
144
  export function launchBlastIfNeeded(self) {
132
- if (self.blastParams) {
145
+ if (self.blastParams && !awaitingTranscript(self)) {
133
146
  runLaunch({
134
147
  self,
135
148
  message: 'Submitting query',
@@ -167,15 +180,28 @@ export function autoLoadProteinDomains(self) {
167
180
  })();
168
181
  }
169
182
  }
170
- // Resolve the declarative `init` launch contract once, then clear it. msaUrl is
171
- // handed to react-msaview's native filehandle loader (openLocation + progress +
172
- // abort + CORS-proxy) and sniffed for an AlphaFold uniprotId; the bgzip
173
- // name-indexed block is the one source with no native loader, so it's fetched
174
- // here. Inline data and tree URLs arrive as native snapshot props, not via init.
183
+ // Resolve the declarative `init` launch contract. msaUrl is handed to
184
+ // react-msaview's native filehandle loader (openLocation + progress + abort +
185
+ // CORS-proxy) and sniffed for an AlphaFold uniprotId; the bgzip name-indexed
186
+ // block is the one source with no native loader, so it's fetched here. Inline
187
+ // data and tree URLs arrive as native snapshot props, not via init.
188
+ //
189
+ // An init that named the indexed block is KEPT rather than cleared, as
190
+ // jbrowse-plugin-tview keeps its own: what it resolves to is one alignment
191
+ // string, react-msaview drops a document over 50kb from the snapshot, and there
192
+ // is no filehandle to reload it from -- so a shared session came back saying the
193
+ // alignment had expired. The init is both smaller than what it fetches and the
194
+ // only durable statement of what the view is.
175
195
  export function processInit(self) {
176
196
  const { init } = self;
177
197
  if (init) {
178
198
  const { msaUrl, msaIndexedLocation, msaName, querySeqName } = init;
199
+ const indexed = !!(msaIndexedLocation && msaName);
200
+ // a kept init re-runs this on every session restore; the alignment already
201
+ // in hand is the one it would fetch
202
+ if (indexed && self.data.msa) {
203
+ return;
204
+ }
179
205
  void (async () => {
180
206
  try {
181
207
  self.setError(undefined);
@@ -204,7 +230,9 @@ export function processInit(self) {
204
230
  throw new Error(`No alignment named ${msaName} in ${msaIndexedLocation.uri}`);
205
231
  }
206
232
  }
207
- self.setInit(undefined);
233
+ if (!indexed) {
234
+ self.setInit(undefined);
235
+ }
208
236
  }
209
237
  catch (e) {
210
238
  self.setError(e);
@@ -238,10 +266,10 @@ export function syncGenomeHoverToMsaColumn(self) {
238
266
  * Translate genome regions published by a 3D protein view into this MSA's
239
267
  * visible columns. The genome is the only coordinate space the two plugins
240
268
  * share, so the hops are genome coord -> protein position (the transcript's g2p
241
- * map) -> global alignment column -> visible column.
269
+ * map) -> visible column.
242
270
  */
243
271
  function genomeHighlightsToVisibleColumns(self, field) {
244
- const { connectedViewId, transcriptToMsaMap, querySeqName } = self;
272
+ const { connectedViewId, transcriptToMsaMap } = self;
245
273
  if (!transcriptToMsaMap || !hasQueryRow(self)) {
246
274
  return [];
247
275
  }
@@ -255,16 +283,17 @@ function genomeHighlightsToVisibleColumns(self, field) {
255
283
  for (const highlight of structure[field] ?? []) {
256
284
  for (let coord = highlight.start; coord < highlight.end; coord++) {
257
285
  const proteinPos = g2p[coord];
258
- if (proteinPos !== undefined) {
259
- columns.add(self.seqPosToGlobalCol(querySeqName, proteinPos));
286
+ const col = proteinPos === undefined
287
+ ? undefined
288
+ : transcriptPosToVisibleCol(self, proteinPos);
289
+ if (col !== undefined) {
290
+ columns.add(col);
260
291
  }
261
292
  }
262
293
  }
263
294
  }
264
295
  }
265
- return [...columns]
266
- .map(col => self.globalColToVisibleCol(col))
267
- .filter((col) => col !== undefined);
296
+ return [...columns];
268
297
  }
269
298
  function sameColumns(a, b) {
270
299
  if (!a || !b) {
@@ -29,16 +29,25 @@ const LaunchProgress = observer(function LaunchProgress2({ model, }) {
29
29
  const { blastParams, orthologParams, progress, rid, error } = model;
30
30
  const { classes } = useStyles();
31
31
  const message = blastParams
32
- ? 'Running EBI BLAST'
32
+ ? `Running EBI ${blastParams.searchProgram === 'phmmer' ? 'phmmer' : 'BLAST'}`
33
33
  : orthologParams
34
- ? 'Building ortholog alignment'
34
+ ? orthologParams.source === 'uniref'
35
+ ? 'Building UniRef homolog alignment'
36
+ : 'Building ortholog alignment'
35
37
  : 'Loading alignment';
36
38
  return (React.createElement("div", { className: classes.margin }, error ? (React.createElement(React.Fragment, null,
37
39
  React.createElement(Typography, { variant: "h5" },
38
40
  message,
39
41
  " failed"),
40
42
  rid ? React.createElement(JobLink, { jobId: rid }) : null,
41
- React.createElement(ErrorMessage, { error: error }))) : (React.createElement(React.Fragment, null,
43
+ React.createElement(ErrorMessage, { error: error }),
44
+ React.createElement("div", { className: classes.progressRow },
45
+ React.createElement(Button, { variant: "outlined", size: "small", onClick: () => {
46
+ model.retryLaunch();
47
+ } }, "Retry"),
48
+ React.createElement(Button, { variant: "outlined", size: "small", onClick: () => {
49
+ model.cancelLaunch();
50
+ } }, "Dismiss")))) : (React.createElement(React.Fragment, null,
42
51
  React.createElement(LoadingEllipses, { message: message, variant: "h5" }),
43
52
  rid ? React.createElement(JobLink, { jobId: rid }) : null,
44
53
  React.createElement("div", { className: classes.progressRow },
@@ -14,8 +14,12 @@ const MsaViewPanel = observer(function MsaViewPanel2({ model, }) {
14
14
  const { classes } = useStyles();
15
15
  const { blastParams, orthologParams, init, loadingStoredData } = model;
16
16
  // an unresolved launch request means there is no alignment to draw yet, so all
17
- // three gate the same panel -- see LaunchProgress
18
- const launching = !!(blastParams ?? orthologParams ?? init);
17
+ // three gate the same panel -- see LaunchProgress. An indexed view keeps its
18
+ // init for the life of the view (it is how the block is refetched), so that
19
+ // one is only "launching" until the alignment arrives.
20
+ const launching = !!(blastParams ??
21
+ orthologParams ??
22
+ (init && !model.dataInitialized));
19
23
  return (React.createElement(ErrorBoundary, null,
20
24
  React.createElement("div", null, launching ? (React.createElement(LaunchProgress, { model: model })) : loadingStoredData ? (React.createElement("div", { className: classes.loadingContainer },
21
25
  React.createElement(LoadingEllipses, { message: "Loading MSA data", variant: "h6" }))) : (React.createElement(MSAView, { model: model })))));
@@ -48,6 +48,16 @@ test('a failed ortholog launch shows why', () => {
48
48
  });
49
49
  expect(screen.getByText(/Only 1 ortholog\(s\) found/)).toBeTruthy();
50
50
  });
51
+ // an indexed view keeps its init -- it is how the block is refetched on the
52
+ // next session -- so the panel cannot read init alone as "still launching"
53
+ test('an indexed view with its alignment draws it, init and all', () => {
54
+ panel({
55
+ init: { msaIndexedLocation: { uri: 'msa.fa.gz' }, msaName: 'ENST1' },
56
+ dataInitialized: true,
57
+ progress: '',
58
+ });
59
+ expect(screen.getByText('the alignment')).toBeTruthy();
60
+ });
51
61
  test('a failed init shows why', () => {
52
62
  panel({
53
63
  init: { msaName: 'ENST00000288602' },
@@ -58,7 +68,7 @@ test('a failed init shows why', () => {
58
68
  expect(screen.getByText(/No alignment named ENST00000288602/)).toBeTruthy();
59
69
  });
60
70
  // a launch runs for 10+ minutes, so leaving with no way out means watching it
61
- test('a running launch offers a way out, and a failed one does not', () => {
71
+ test('a running launch offers a way out', () => {
62
72
  const cancelLaunch = vi.fn();
63
73
  panel({
64
74
  blastParams: { proteinSequence: 'MKV' },
@@ -67,14 +77,25 @@ test('a running launch offers a way out, and a failed one does not', () => {
67
77
  });
68
78
  screen.getByRole('button', { name: 'Cancel' }).click();
69
79
  expect(cancelLaunch).toHaveBeenCalled();
70
- cleanup();
80
+ });
81
+ // the request outlives the failure -- it is what a reload resubmits -- so a
82
+ // failed launch that only drew the error was a dead end that re-ran the EBI job
83
+ // on every reload
84
+ test('a failed launch can be retried or dismissed', () => {
85
+ const cancelLaunch = vi.fn();
86
+ const retryLaunch = vi.fn();
71
87
  panel({
72
88
  blastParams: { proteinSequence: 'MKV' },
73
89
  progress: '',
74
90
  error: new Error('No hits found'),
75
91
  cancelLaunch,
92
+ retryLaunch,
76
93
  });
77
94
  expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
95
+ screen.getByRole('button', { name: 'Retry' }).click();
96
+ expect(retryLaunch).toHaveBeenCalled();
97
+ screen.getByRole('button', { name: 'Dismiss' }).click();
98
+ expect(cancelLaunch).toHaveBeenCalled();
78
99
  });
79
100
  test('a running job links out to it', () => {
80
101
  panel({
@@ -1,5 +1,13 @@
1
1
  import type { JBrowsePluginMsaViewModel } from './model';
2
2
  import type { LaunchScope } from './runLaunch';
3
+ /**
4
+ * A similarity search, then an alignment of what it found. The program is a
5
+ * backend behind one interface (utils/homologSearch.ts); what differs between
6
+ * them is settled by whether the result came back aligned. A program that
7
+ * aligns as it searches (phmmer) hands over the alignment and the tree is
8
+ * built from it in the browser; one that does not (blastp) hands over bare
9
+ * hits and the chosen aligner runs on them.
10
+ */
3
11
  export declare function doLaunchBlast({ self, scope, }: {
4
12
  self: JBrowsePluginMsaViewModel;
5
13
  scope: LaunchScope;
@@ -1,114 +1,99 @@
1
- import { makeId, strip } from '../LaunchMsaView/components/util';
2
1
  import { cleanProteinSequence } from '../LaunchMsaView/util';
3
2
  import { saveBlastResult } from '../utils/blastCache';
4
- import { queryEbiBlast } from '../utils/ebiBlast';
5
- import { launchMSA, launchTree } from '../utils/msa';
6
- import { buildPhmmerMsa, buildRowMetadata } from '../utils/msaRows';
7
- import { queryPhmmer } from '../utils/phmmer';
3
+ import { searchBackends } from '../utils/homologSearch';
4
+ import { launchMSA } from '../utils/msa';
5
+ import { buildSearchMsa } from '../utils/msaRows';
8
6
  import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
9
- export async function doLaunchBlast({ self, scope, }) {
10
- // kept whole rather than destructured: the database's type depends on
11
- // searchProgram, and pulling the two apart loses the link between them
12
- const params = self.blastParams;
13
- const { selectedTranscript } = params;
14
- const cleanedSeq = cleanProteinSequence(params.proteinSequence);
15
- const { onProgress, onRid, signal } = scope;
16
- const { msa, tree, treeMetadata, rid } = params.searchProgram === 'phmmer'
17
- ? await runPhmmer({
18
- query: cleanedSeq,
19
- database: params.blastDatabase,
20
- onProgress,
21
- onRid,
22
- signal,
23
- })
24
- : await runBlast({
25
- query: cleanedSeq,
26
- blastDatabase: params.blastDatabase,
27
- msaAlgorithm: params.msaAlgorithm,
28
- onProgress,
29
- onRid,
30
- signal,
31
- });
32
- const treeMetadataJson = JSON.stringify(treeMetadata);
33
- await saveBlastResult({
34
- proteinSequence: cleanedSeq,
35
- blastDatabase: params.blastDatabase,
36
- msaAlgorithm: params.msaAlgorithm,
37
- searchProgram: params.searchProgram,
38
- msa,
39
- tree,
40
- treeMetadata: treeMetadataJson,
41
- rid,
42
- geneId: selectedTranscript?.get('parentId'),
43
- transcriptId: selectedTranscript?.id(),
44
- transcriptName: selectedTranscript?.get('name') ?? selectedTranscript?.get('id'),
45
- geneName: selectedTranscript?.get('gene_name') ??
46
- selectedTranscript?.get('parentId'),
47
- });
48
- return { msa, tree, treeMetadata: treeMetadataJson };
7
+ import { resolveUniProtEntry } from '../utils/unirefHomologs';
8
+ import { transcriptFields, transcriptName } from './util';
9
+ function asString(val) {
10
+ return typeof val === 'string' ? val : undefined;
49
11
  }
50
12
  /**
51
- * BLAST returns each hit already aligned to the query, but pairwise and one hit
52
- * at a time, so the alignments are stripped back off and every hit is realigned
53
- * together by a dedicated aligner.
13
+ * The query sequence, and what its row is called. The dialog hands over the
14
+ * translated transcript and the row stays `QUERY`; a spec naming a UniProt
15
+ * accession has the sequence fetched and the row named after the entry
16
+ * (`P53_HUMAN_query`), since a search of swissprot returns the entry itself
17
+ * as a hit and two rows called the same thing collapse into one.
54
18
  */
55
- async function runBlast({ query, blastDatabase, msaAlgorithm, onProgress, onRid, signal, }) {
56
- const { hits, rid } = await queryEbiBlast({
57
- query,
58
- blastDatabase,
59
- onProgress,
60
- onRid,
61
- signal,
62
- });
63
- onProgress('Fetching species taxonomy info...');
64
- const taxonomyInfo = await fetchTaxonomyInfo(hits
65
- .map(h => h.description[0]?.taxid)
66
- .filter((t) => t !== undefined));
67
- const treeMetadata = {};
68
- const sequences = hits.map(h => {
69
- const desc = h.description[0] ?? {
70
- accession: 'unknown',
71
- id: 'unknown',
72
- sciname: 'unknown',
19
+ async function resolveQuery(self, scope) {
20
+ const params = self.blastParams;
21
+ if (params.proteinSequence) {
22
+ return {
23
+ sequence: cleanProteinSequence(params.proteinSequence),
24
+ name: self.querySeqName,
73
25
  };
74
- const rowName = makeId(desc, taxonomyInfo);
75
- treeMetadata[rowName] = buildRowMetadata(desc, taxonomyInfo);
76
- return `>${rowName}\n${strip(h.hsps[0]?.hseq ?? '')}`;
77
- });
78
- const result = await launchMSA({
79
- algorithm: msaAlgorithm,
80
- sequence: [`>QUERY\n${query}`, ...sequences].join('\n'),
81
- onProgress,
82
- signal,
83
- });
84
- return { ...result, treeMetadata, rid };
26
+ }
27
+ if (params.accession) {
28
+ scope.onProgress(`Fetching ${params.accession} from UniProt...`);
29
+ const entry = await resolveUniProtEntry([params.accession], 0, scope.signal);
30
+ if (!entry) {
31
+ throw new Error(`UniProt has no entry ${params.accession}`);
32
+ }
33
+ const name = `${entry.id}_query`;
34
+ scope.act(() => {
35
+ self.setQuerySeqName(name);
36
+ });
37
+ return { sequence: entry.sequence, name };
38
+ }
39
+ throw new Error('No query: a search needs a proteinSequence, a UniProt accession, or a connectedTranscript to translate');
85
40
  }
86
41
  /**
87
- * phmmer aligns every hit to a profile of the query as it searches, so its own
88
- * output is the MSA and there is no realignment step — the hits keep the
89
- * placement HMMER gave them, and the query row is derived from the alignment's
90
- * match columns rather than being aligned back in afterwards. That leaves no
91
- * aligner run to take a tree from, so the tree is built from this alignment.
42
+ * A similarity search, then an alignment of what it found. The program is a
43
+ * backend behind one interface (utils/homologSearch.ts); what differs between
44
+ * them is settled by whether the result came back aligned. A program that
45
+ * aligns as it searches (phmmer) hands over the alignment and the tree is
46
+ * built from it in the browser; one that does not (blastp) hands over bare
47
+ * hits and the chosen aligner runs on them.
92
48
  */
93
- async function runPhmmer({ query, database, onProgress, onRid, signal, }) {
94
- const { rows, queryRow, rid } = await queryPhmmer({
49
+ export async function doLaunchBlast({ self, scope, }) {
50
+ const params = self.blastParams;
51
+ const { selectedTranscript, maxHits, searchProgram = 'blastp' } = params;
52
+ const { sequence: query, name: querySeqName } = await resolveQuery(self, scope);
53
+ const { onProgress, onRid, signal } = scope;
54
+ const { hits, queryRow, rid } = await searchBackends[searchProgram]({
95
55
  query,
96
- database,
56
+ database: params.blastDatabase,
57
+ maxHits,
97
58
  onProgress,
98
59
  onRid,
99
60
  signal,
100
61
  });
62
+ if (hits.length === 0) {
63
+ throw new Error('No hits found');
64
+ }
101
65
  onProgress('Fetching species taxonomy info...');
102
- const taxonomyInfo = await fetchTaxonomyInfo(rows.map(r => r.taxid).filter((t) => t !== undefined));
103
- const { msa, treeMetadata } = buildPhmmerMsa({
104
- rows,
66
+ const taxonomyInfo = await fetchTaxonomyInfo(hits.map(h => h.taxid).filter((t) => t !== undefined));
67
+ const { msa: fasta, treeMetadata } = buildSearchMsa({
68
+ hits,
69
+ query,
105
70
  queryRow,
106
71
  taxonomyInfo,
72
+ querySeqName,
107
73
  });
108
- return {
74
+ const { msa, tree } = queryRow
75
+ ? { msa: fasta, tree: '' }
76
+ : await launchMSA({
77
+ algorithm: params.msaAlgorithm ?? 'browser',
78
+ sequence: fasta,
79
+ onProgress,
80
+ signal,
81
+ });
82
+ const transcript = transcriptFields(selectedTranscript);
83
+ const treeMetadataJson = JSON.stringify(treeMetadata);
84
+ await saveBlastResult({
85
+ proteinSequence: query,
86
+ blastDatabase: params.blastDatabase,
87
+ msaAlgorithm: params.msaAlgorithm,
88
+ searchProgram: params.searchProgram,
109
89
  msa,
110
- tree: await launchTree({ alignment: msa, onProgress, signal }),
111
- treeMetadata,
112
- rid,
113
- };
90
+ tree,
91
+ treeMetadata: treeMetadataJson,
92
+ rid: rid ?? '',
93
+ geneId: asString(transcript.parentId),
94
+ transcriptId: asString(transcript.uniqueId),
95
+ transcriptName: transcriptName(selectedTranscript),
96
+ geneName: asString(transcript.gene_name) ?? asString(transcript.parentId),
97
+ });
98
+ return { msa, tree, treeMetadata: treeMetadataJson };
114
99
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,158 @@
1
+ import { beforeEach, expect, test, vi } from 'vitest';
2
+ import { saveBlastResult } from '../utils/blastCache';
3
+ import { searchBackends } from '../utils/homologSearch';
4
+ import { launchMSA } from '../utils/msa';
5
+ import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
6
+ import { resolveUniProtEntry } from '../utils/unirefHomologs';
7
+ import { doLaunchBlast } from './doLaunchBlast';
8
+ // Every network call is mocked: what is under test is how the launch turns a
9
+ // request into a search and the search's answer into rows -- which backend it
10
+ // asks, where the query comes from, and whether an aligner runs.
11
+ vi.mock('../utils/homologSearch', () => ({
12
+ searchBackends: { blastp: vi.fn(), phmmer: vi.fn() },
13
+ }));
14
+ vi.mock('../utils/msa', () => ({ launchMSA: vi.fn() }));
15
+ vi.mock('../utils/taxonomyNames', () => ({ fetchTaxonomyInfo: vi.fn() }));
16
+ vi.mock('../utils/blastCache', () => ({ saveBlastResult: vi.fn() }));
17
+ vi.mock('../utils/unirefHomologs', () => ({ resolveUniProtEntry: vi.fn() }));
18
+ const blastp = vi.mocked(searchBackends.blastp);
19
+ const phmmer = vi.mocked(searchBackends.phmmer);
20
+ const mockLaunchMSA = vi.mocked(launchMSA);
21
+ const setQuerySeqName = vi.fn();
22
+ const HIT = { accession: 'P1', id: 'P1_MOUSE', sciname: 'Mus musculus' };
23
+ function makeModel(blastParams) {
24
+ return {
25
+ blastParams,
26
+ querySeqName: 'QUERY',
27
+ setQuerySeqName,
28
+ };
29
+ }
30
+ function launch(self) {
31
+ return doLaunchBlast({
32
+ self,
33
+ scope: {
34
+ signal: new AbortController().signal,
35
+ act: fn => {
36
+ fn();
37
+ },
38
+ onProgress: () => { },
39
+ onRid: () => { },
40
+ },
41
+ });
42
+ }
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ vi.mocked(fetchTaxonomyInfo).mockResolvedValue(new Map());
46
+ vi.mocked(saveBlastResult).mockResolvedValue(undefined);
47
+ });
48
+ test('bare hits go to the chosen aligner, with the query first', async () => {
49
+ blastp.mockResolvedValue({ rid: 'job', hits: [{ ...HIT, sequence: 'MKWV' }] });
50
+ mockLaunchMSA.mockResolvedValue({ msa: 'aligned', tree: 'tree' });
51
+ const result = await launch(makeModel({
52
+ searchProgram: 'blastp',
53
+ blastDatabase: 'uniprotkb_swissprot',
54
+ msaAlgorithm: 'clustalo',
55
+ maxHits: 20,
56
+ proteinSequence: 'MKWVTF*',
57
+ }));
58
+ expect(blastp).toHaveBeenCalledWith(expect.objectContaining({
59
+ query: 'MKWVTF',
60
+ database: 'uniprotkb_swissprot',
61
+ maxHits: 20,
62
+ }));
63
+ expect(mockLaunchMSA).toHaveBeenCalledWith(expect.objectContaining({
64
+ algorithm: 'clustalo',
65
+ sequence: '>QUERY\nMKWVTF\n>P1-Mus_musculus\nMKWV',
66
+ }));
67
+ expect(result.msa).toBe('aligned');
68
+ expect(result.tree).toBe('tree');
69
+ });
70
+ test('an aligned result skips the aligner and leaves the tree to the browser', async () => {
71
+ phmmer.mockResolvedValue({
72
+ rid: 'job',
73
+ queryRow: 'MKWV-TF',
74
+ hits: [{ ...HIT, sequence: 'MKWVSTF' }],
75
+ });
76
+ const result = await launch(makeModel({
77
+ searchProgram: 'phmmer',
78
+ blastDatabase: 'rp15',
79
+ proteinSequence: 'MKWVTF',
80
+ }));
81
+ expect(mockLaunchMSA).not.toHaveBeenCalled();
82
+ expect(result.msa).toBe('>QUERY\nMKWV-TF\n>P1-Mus_musculus\nMKWVSTF');
83
+ expect(result.tree).toBe('');
84
+ });
85
+ test('a UniProt accession supplies the query and names its row', async () => {
86
+ vi.mocked(resolveUniProtEntry).mockResolvedValue({
87
+ accession: 'P04637',
88
+ id: 'P53_HUMAN',
89
+ reviewed: true,
90
+ taxId: 9606,
91
+ scientificName: 'Homo sapiens',
92
+ sequence: 'MEEPQ',
93
+ });
94
+ phmmer.mockResolvedValue({
95
+ queryRow: 'MEEPQ',
96
+ hits: [{ ...HIT, sequence: 'MEEPQ' }],
97
+ });
98
+ const result = await launch(makeModel({
99
+ searchProgram: 'phmmer',
100
+ blastDatabase: 'swissprot',
101
+ accession: 'P04637',
102
+ }));
103
+ expect(resolveUniProtEntry).toHaveBeenCalledWith(['P04637'], 0, expect.any(AbortSignal));
104
+ expect(setQuerySeqName).toHaveBeenCalledWith('P53_HUMAN_query');
105
+ expect(result.msa.split('\n')[0]).toBe('>P53_HUMAN_query');
106
+ });
107
+ test('a request with no query at all is refused before any search runs', async () => {
108
+ await expect(launch(makeModel({ searchProgram: 'phmmer', blastDatabase: 'swissprot' }))).rejects.toThrow(/connectedTranscript/);
109
+ expect(phmmer).not.toHaveBeenCalled();
110
+ });
111
+ // blastParams is a frozen snapshot property, so the Feature the dialog put in
112
+ // it comes back from a session reload as the plain JSON it serialized to. The
113
+ // launch used to call .get() on that, minutes after the EBI job the reload
114
+ // resubmitted had come back, and threw where nothing was catching.
115
+ test('a transcript restored from a session snapshot still labels the cache row', async () => {
116
+ blastp.mockResolvedValue({ rid: 'job', hits: [{ ...HIT, sequence: 'MKWV' }] });
117
+ mockLaunchMSA.mockResolvedValue({ msa: 'aligned', tree: 'tree' });
118
+ await launch(makeModel({
119
+ searchProgram: 'blastp',
120
+ blastDatabase: 'uniprotkb_swissprot',
121
+ msaAlgorithm: 'clustalo',
122
+ proteinSequence: 'MKWV',
123
+ selectedTranscript: {
124
+ uniqueId: 'NM_000546.6',
125
+ name: 'TP53-201',
126
+ parentId: 'TP53',
127
+ gene_name: 'TP53',
128
+ },
129
+ }));
130
+ expect(saveBlastResult).toHaveBeenCalledWith(expect.objectContaining({
131
+ geneId: 'TP53',
132
+ transcriptId: 'NM_000546.6',
133
+ transcriptName: 'TP53-201',
134
+ geneName: 'TP53',
135
+ }));
136
+ });
137
+ test('a live Feature handed over in the same session labels it the same way', async () => {
138
+ blastp.mockResolvedValue({ rid: 'job', hits: [{ ...HIT, sequence: 'MKWV' }] });
139
+ mockLaunchMSA.mockResolvedValue({ msa: 'aligned', tree: 'tree' });
140
+ const json = {
141
+ uniqueId: 'NM_000546.6',
142
+ name: 'TP53-201',
143
+ parentId: 'TP53',
144
+ };
145
+ await launch(makeModel({
146
+ searchProgram: 'blastp',
147
+ blastDatabase: 'uniprotkb_swissprot',
148
+ msaAlgorithm: 'clustalo',
149
+ proteinSequence: 'MKWV',
150
+ selectedTranscript: { toJSON: () => json },
151
+ }));
152
+ expect(saveBlastResult).toHaveBeenCalledWith(expect.objectContaining({
153
+ geneId: 'TP53',
154
+ transcriptId: 'NM_000546.6',
155
+ transcriptName: 'TP53-201',
156
+ geneName: 'TP53',
157
+ }));
158
+ });