jbrowse-plugin-msaview 3.6.0 → 3.7.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 (156) hide show
  1. package/README.md +8 -0
  2. package/dist/LaunchMsaView/cleanProteinSequence.test.d.ts +1 -0
  3. package/dist/LaunchMsaView/cleanProteinSequence.test.js +77 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +29 -32
  5. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +10 -15
  6. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.js +14 -13
  7. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +25 -22
  8. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -19
  9. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +2 -1
  10. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +18 -2
  11. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.d.ts +1 -0
  12. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.js +13 -0
  13. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +19 -14
  14. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +3 -1
  15. package/dist/LaunchMsaView/components/SubmitCancelActions.js +10 -7
  16. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +20 -2
  17. package/dist/LaunchMsaView/components/calculateProteinSequence.d.ts +8 -1
  18. package/dist/LaunchMsaView/components/calculateProteinSequence.js +25 -20
  19. package/dist/LaunchMsaView/components/launchConnectedView.d.ts +23 -0
  20. package/dist/LaunchMsaView/components/launchConnectedView.js +39 -0
  21. package/dist/LaunchMsaView/detectQueryRow.js +4 -7
  22. package/dist/LaunchMsaView/util.d.ts +6 -0
  23. package/dist/LaunchMsaView/util.js +7 -1
  24. package/dist/LaunchMsaViewExtensionPoint/index.test.js +15 -0
  25. package/dist/MsaViewPanel/afterCreateAutoruns.js +12 -3
  26. package/dist/MsaViewPanel/applyRegion.d.ts +23 -0
  27. package/dist/MsaViewPanel/applyRegion.js +19 -0
  28. package/dist/MsaViewPanel/applyRegion.test.d.ts +1 -0
  29. package/dist/MsaViewPanel/applyRegion.test.js +45 -0
  30. package/dist/MsaViewPanel/doLaunchBlast.js +2 -1
  31. package/dist/MsaViewPanel/doLaunchBlast.test.js +12 -0
  32. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +1 -1
  33. package/dist/MsaViewPanel/genomeToMSA.js +3 -3
  34. package/dist/MsaViewPanel/genomeToMSA.test.js +69 -23
  35. package/dist/MsaViewPanel/launchAutoruns.test.d.ts +1 -0
  36. package/dist/MsaViewPanel/launchAutoruns.test.js +36 -0
  37. package/dist/MsaViewPanel/model.d.ts +18 -14
  38. package/dist/MsaViewPanel/model.js +17 -0
  39. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  40. package/dist/MsaViewPanel/msaDataStore.js +3 -12
  41. package/dist/MsaViewPanel/msaDataStore.test.js +8 -1
  42. package/dist/MsaViewPanel/observeProteinHighlights.test.js +4 -4
  43. package/dist/MsaViewPanel/regionSnapshot.test.d.ts +1 -0
  44. package/dist/MsaViewPanel/regionSnapshot.test.js +29 -0
  45. package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +41 -4
  46. package/dist/MsaViewPanel/resolveConnectedTranscript.js +103 -68
  47. package/dist/MsaViewPanel/resolveConnectedTranscript.test.d.ts +1 -0
  48. package/dist/MsaViewPanel/resolveConnectedTranscript.test.js +149 -0
  49. package/dist/MsaViewPanel/runLaunch.d.ts +8 -4
  50. package/dist/MsaViewPanel/runLaunch.js +15 -4
  51. package/dist/MsaViewPanel/runLaunch.test.js +16 -0
  52. package/dist/MsaViewPanel/storedData.test.js +23 -0
  53. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +3 -2
  54. package/dist/MsaViewPanel/util.d.ts +23 -23
  55. package/dist/MsaViewPanel/util.js +23 -24
  56. package/dist/index.js +17 -10
  57. package/dist/jbrowse-plugin-msaview.umd.production.min.js +36 -36
  58. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  59. package/dist/utils/blastCache.d.ts +26 -2
  60. package/dist/utils/blastCache.js +51 -39
  61. package/dist/utils/blastCache.test.js +46 -19
  62. package/dist/utils/domainCache.d.ts +1 -1
  63. package/dist/utils/domainCache.js +18 -14
  64. package/dist/utils/ebiJobDispatcher.d.ts +11 -0
  65. package/dist/utils/ebiJobDispatcher.js +12 -13
  66. package/dist/utils/ebiJobDispatcher.test.js +17 -3
  67. package/dist/utils/eutils.d.ts +14 -0
  68. package/dist/utils/eutils.js +48 -14
  69. package/dist/utils/eutils.test.d.ts +1 -0
  70. package/dist/utils/eutils.test.js +31 -0
  71. package/dist/utils/idb.d.ts +16 -3
  72. package/dist/utils/idb.js +51 -5
  73. package/dist/utils/idb.test.d.ts +1 -0
  74. package/dist/utils/idb.test.js +78 -0
  75. package/dist/utils/ncbiDomains.js +4 -4
  76. package/dist/utils/ncbiOrthologs.d.ts +1 -1
  77. package/dist/utils/ncbiOrthologs.js +18 -17
  78. package/dist/utils/ncbiOrthologs.test.js +17 -1
  79. package/dist/utils/ncbiTaxonomy.js +14 -15
  80. package/dist/utils/taxonomyNames.js +23 -19
  81. package/dist/utils/taxonomyNames.test.d.ts +1 -0
  82. package/dist/utils/taxonomyNames.test.js +33 -0
  83. package/dist/utils/unirefHomologs.js +2 -2
  84. package/dist/utils/unirefHomologs.test.js +8 -1
  85. package/dist/version.d.ts +1 -1
  86. package/dist/version.js +1 -1
  87. package/package.json +9 -9
  88. package/src/LaunchMsaView/cleanProteinSequence.test.ts +91 -0
  89. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +35 -32
  90. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +10 -14
  91. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.tsx +16 -15
  92. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +34 -21
  93. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +18 -21
  94. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.ts +16 -0
  95. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +24 -2
  96. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +19 -13
  97. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +27 -2
  98. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +12 -7
  99. package/src/LaunchMsaView/components/calculateProteinSequence.ts +28 -18
  100. package/src/LaunchMsaView/components/launchConnectedView.ts +56 -0
  101. package/src/LaunchMsaView/detectQueryRow.ts +5 -7
  102. package/src/LaunchMsaView/util.ts +7 -1
  103. package/src/LaunchMsaViewExtensionPoint/index.test.ts +16 -0
  104. package/src/LaunchMsaViewExtensionPoint/index.ts +28 -1
  105. package/src/MsaViewPanel/afterCreateAutoruns.ts +12 -3
  106. package/src/MsaViewPanel/applyRegion.test.ts +55 -0
  107. package/src/MsaViewPanel/applyRegion.ts +33 -0
  108. package/src/MsaViewPanel/doLaunchBlast.test.ts +20 -1
  109. package/src/MsaViewPanel/doLaunchBlast.ts +2 -1
  110. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +1 -1
  111. package/src/MsaViewPanel/genomeToMSA.test.ts +75 -23
  112. package/src/MsaViewPanel/genomeToMSA.ts +3 -2
  113. package/src/MsaViewPanel/launchAutoruns.test.ts +45 -0
  114. package/src/MsaViewPanel/model.ts +22 -0
  115. package/src/MsaViewPanel/msaDataStore.test.ts +14 -2
  116. package/src/MsaViewPanel/msaDataStore.ts +4 -12
  117. package/src/MsaViewPanel/observeProteinHighlights.test.ts +4 -4
  118. package/src/MsaViewPanel/regionSnapshot.test.ts +34 -0
  119. package/src/MsaViewPanel/resolveConnectedTranscript.test.ts +183 -0
  120. package/src/MsaViewPanel/resolveConnectedTranscript.ts +154 -71
  121. package/src/MsaViewPanel/runLaunch.test.ts +19 -0
  122. package/src/MsaViewPanel/runLaunch.ts +21 -4
  123. package/src/MsaViewPanel/storedData.test.ts +32 -0
  124. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +3 -2
  125. package/src/MsaViewPanel/util.ts +38 -29
  126. package/src/index.ts +19 -10
  127. package/src/utils/blastCache.test.ts +59 -20
  128. package/src/utils/blastCache.ts +62 -39
  129. package/src/utils/domainCache.ts +27 -15
  130. package/src/utils/ebiJobDispatcher.test.ts +18 -3
  131. package/src/utils/ebiJobDispatcher.ts +12 -16
  132. package/src/utils/eutils.test.ts +39 -0
  133. package/src/utils/eutils.ts +64 -14
  134. package/src/utils/idb.test.ts +106 -0
  135. package/src/utils/idb.ts +57 -5
  136. package/src/utils/ncbiDomains.ts +4 -4
  137. package/src/utils/ncbiOrthologs.test.ts +20 -0
  138. package/src/utils/ncbiOrthologs.ts +26 -28
  139. package/src/utils/ncbiTaxonomy.ts +17 -20
  140. package/src/utils/taxonomyNames.test.ts +41 -0
  141. package/src/utils/taxonomyNames.ts +33 -19
  142. package/src/utils/unirefHomologs.test.ts +11 -1
  143. package/src/utils/unirefHomologs.ts +2 -2
  144. package/src/version.ts +1 -1
  145. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.d.ts +0 -16
  146. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +0 -31
  147. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +0 -16
  148. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +0 -16
  149. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +0 -9
  150. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +0 -15
  151. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.d.ts +0 -13
  152. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +0 -22
  153. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +0 -59
  154. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +0 -43
  155. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +0 -31
  156. package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +0 -42
@@ -1,22 +1,10 @@
1
1
  import { getConf } from '@jbrowse/core/configuration';
2
2
  import { getSession } from '@jbrowse/core/util';
3
+ import { isAlive } from '@jbrowse/mobx-state-tree';
4
+ import { transaction, untracked } from 'mobx';
3
5
  import { getProteinSequenceFromFeature } from '../LaunchMsaView/components/calculateProteinSequence';
4
6
  import { fetchSeq } from '../LaunchMsaView/components/fetchSeq';
5
7
  import { getTranscriptFeatures } from '../LaunchMsaView/util';
6
- // The short form of a connected launch. A spec that says
7
- //
8
- // {"type":"MsaView","connectedViewId":"lgv1","connectedTranscript":"NM_000546.6", ...}
9
- //
10
- // gets its `connectedFeature` -- the exon model every genome<->column mapping
11
- // runs through -- looked up here, from the gene tracks the connected genome
12
- // view has open, and its query row translated from that transcript. Before
13
- // this the spec had to carry the transcript's CDS list itself, ~1.5kB of
14
- // coordinates a person cannot type and a tutorial cannot show.
15
- //
16
- // jbrowse-plugin-protein3d does the same for its `transcriptId` shorthand,
17
- // against a `connectedView` spec of its own; here the genome view already
18
- // exists (the spec pinned its id), so its tracks and regions are read off the
19
- // live model instead.
20
8
  function stripVersion(id) {
21
9
  return id.replace(/\.\d+$/, '');
22
10
  }
@@ -29,28 +17,51 @@ function transcriptMatches(transcript, id) {
29
17
  transcript.id(),
30
18
  ].some(c => typeof c === 'string' && (c === id || stripVersion(c) === target));
31
19
  }
20
+ function plainRegions(regions) {
21
+ return regions.map(r => ({
22
+ assemblyName: r.assemblyName,
23
+ refName: r.refName,
24
+ start: Math.floor(r.start),
25
+ end: Math.ceil(r.end),
26
+ }));
27
+ }
28
+ /**
29
+ * The part of the genome on screen, then everything the view can scroll to.
30
+ * `displayedRegions` is usually whole chromosomes -- navigating to a locus
31
+ * displays its parent region -- so it is the fallback, not the first ask.
32
+ */
33
+ export function searchWindows(view) {
34
+ const visible = plainRegions(view.dynamicBlocks?.contentBlocks ?? []);
35
+ const displayed = plainRegions(view.displayedRegions);
36
+ return visible.length > 0 ? [visible, displayed] : [displayed];
37
+ }
38
+ /**
39
+ * Gene models live on feature tracks. Asking an alignments, variant or
40
+ * quantitative track for a chromosome's worth of features to look for a
41
+ * transcript among them is the slowest possible way to find nothing.
42
+ */
43
+ export function transcriptTracks(tracks) {
44
+ return tracks.filter(t => t.type === 'FeatureTrack');
45
+ }
32
46
  async function findTranscript(self, transcriptId) {
33
47
  const view = self.connectedView;
34
48
  const session = getSession(self);
35
49
  const sessionId = 'msaview-connectedTranscript';
36
- const regions = view.displayedRegions.map(r => ({
37
- assemblyName: r.assemblyName,
38
- refName: r.refName,
39
- start: r.start,
40
- end: r.end,
41
- }));
42
- for (const track of view.tracks) {
43
- // a named object keeps sessionId, which v4 hosts read from the args
44
- const args = {
45
- adapterConfig: getConf(track, 'adapter'),
46
- sessionId,
47
- regions,
48
- };
49
- const feats = await session.rpcManager.call(sessionId, 'CoreGetFeatures', args);
50
- for (const feat of feats) {
51
- const hit = getTranscriptFeatures(feat).find(t => transcriptMatches(t, transcriptId));
52
- if (hit) {
53
- return hit;
50
+ const tracks = transcriptTracks(view.tracks);
51
+ for (const regions of searchWindows(view)) {
52
+ for (const track of tracks) {
53
+ // a named object keeps sessionId, which v4 hosts read from the args
54
+ const args = {
55
+ adapterConfig: getConf(track, 'adapter'),
56
+ sessionId,
57
+ regions,
58
+ };
59
+ const feats = await session.rpcManager.call(sessionId, 'CoreGetFeatures', args);
60
+ for (const feat of feats) {
61
+ const hit = getTranscriptFeatures(feat).find(t => transcriptMatches(t, transcriptId));
62
+ if (hit) {
63
+ return hit;
64
+ }
54
65
  }
55
66
  }
56
67
  }
@@ -72,37 +83,30 @@ async function translate(self, transcript) {
72
83
  assemblyGeneticCodeId,
73
84
  });
74
85
  }
75
- /**
76
- * Resolve `connectedTranscript` into `connectedFeature` once the connected
77
- * genome view has its assembly, regions and tracks, then hand the translation
78
- * to whichever launch is waiting for a query. Left in place on failure so the
79
- * error stays attributable; the autorun's tracked reads are the view's
80
- * readiness, so nothing refires until that changes.
81
- */
82
- export function resolveConnectedTranscriptIfNeeded(self) {
83
- const { connectedTranscript, connectedFeature, connectedView } = self;
84
- if (!connectedTranscript ||
85
- connectedFeature ||
86
- !connectedView?.initialized ||
87
- connectedView.tracks.length === 0) {
88
- return;
89
- }
90
- const trackCount = connectedView.tracks.length;
91
- void (async () => {
92
- try {
93
- self.setProgress(`Looking up ${connectedTranscript} in the genome view...`);
94
- const transcript = await findTranscript(self, connectedTranscript);
95
- if (!transcript) {
96
- throw new Error(`Transcript "${connectedTranscript}" was not found in the ${trackCount} track(s) open in the connected genome view. Open the gene track that carries it, and make sure the view is on its locus.`);
97
- }
98
- const proteinSequence = await translate(self, transcript);
99
- if (!proteinSequence) {
100
- throw new Error(`Transcript "${connectedTranscript}" has no CDS to translate`);
101
- }
102
- // toJSON, not the Feature: the params are frozen snapshot properties, so
103
- // an instance in one is gone the moment the session reloads
104
- const transcriptJson = transcript.toJSON();
105
- const { blastParams, orthologParams } = self;
86
+ /** the latest lookup per view; an older one finishing late writes nothing */
87
+ const latestLookup = new WeakMap();
88
+ async function lookUp({ self, transcriptId, blastParams, orthologParams, isCurrent, }) {
89
+ const trackCount = transcriptTracks(self.connectedView.tracks).length;
90
+ try {
91
+ self.setProgress(`Looking up ${transcriptId} in the genome view...`);
92
+ const transcript = await findTranscript(self, transcriptId);
93
+ if (!transcript) {
94
+ throw new Error(`Transcript "${transcriptId}" was not found in the ${trackCount} feature track(s) open in the connected genome view. Open the gene track that carries it, and make sure the view is on its locus.`);
95
+ }
96
+ const proteinSequence = await translate(self, transcript);
97
+ if (!proteinSequence) {
98
+ throw new Error(`Transcript "${transcriptId}" has no CDS to translate`);
99
+ }
100
+ if (!isCurrent()) {
101
+ return;
102
+ }
103
+ // toJSON, not the Feature: the params are frozen snapshot properties, so
104
+ // an instance in one is gone the moment the session reloads
105
+ const transcriptJson = transcript.toJSON();
106
+ // one transaction, so the launchers wake once, on a request that already
107
+ // carries its query
108
+ transaction(() => {
109
+ self.setProgress('');
106
110
  if (blastParams && !blastParams.proteinSequence) {
107
111
  self.setBlastParams({
108
112
  ...blastParams,
@@ -117,12 +121,43 @@ export function resolveConnectedTranscriptIfNeeded(self) {
117
121
  proteinSequence,
118
122
  });
119
123
  }
120
- // last, because the launchers wake on it
121
124
  self.setConnectedFeature(transcriptJson);
122
- }
123
- catch (e) {
124
- console.error(e);
125
+ });
126
+ }
127
+ catch (e) {
128
+ console.error(e);
129
+ if (isCurrent()) {
130
+ self.setProgress('');
125
131
  self.setError(e);
126
132
  }
127
- })();
133
+ }
134
+ }
135
+ /**
136
+ * Resolve `connectedTranscript` into `connectedFeature` once the connected
137
+ * genome view has its assembly and tracks, then hand the translation to
138
+ * whichever launch is waiting for a query.
139
+ *
140
+ * The autorun re-fires on the view's readiness (a track opened after a miss)
141
+ * and on the request itself, which is what `retryLaunch` re-states. The lookup
142
+ * runs untracked, so scrolling the genome view is not a reason to look again.
143
+ */
144
+ export function resolveConnectedTranscriptIfNeeded(self) {
145
+ const { connectedTranscript, connectedFeature, connectedView, blastParams, orthologParams, } = self;
146
+ if (!connectedTranscript ||
147
+ connectedFeature ||
148
+ !connectedView?.initialized ||
149
+ connectedView.tracks.length === 0) {
150
+ return;
151
+ }
152
+ const lookup = (latestLookup.get(self) ?? 0) + 1;
153
+ latestLookup.set(self, lookup);
154
+ untracked(() => {
155
+ void lookUp({
156
+ self,
157
+ transcriptId: connectedTranscript,
158
+ blastParams,
159
+ orthologParams,
160
+ isCurrent: () => isAlive(self) && latestLookup.get(self) === lookup,
161
+ });
162
+ });
128
163
  }
@@ -0,0 +1,149 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import stateModelFactory from './model';
3
+ const rpcCall = vi.fn();
4
+ const lgv = {
5
+ id: 'lgv1',
6
+ initialized: true,
7
+ assemblyNames: ['hg38'],
8
+ tracks: [],
9
+ displayedRegions: [
10
+ { assemblyName: 'hg38', refName: 'chr17', start: 0, end: 83_257_441 },
11
+ ],
12
+ dynamicBlocks: {
13
+ contentBlocks: [],
14
+ },
15
+ };
16
+ vi.mock('@jbrowse/core/util', async (importOriginal) => ({
17
+ ...(await importOriginal()),
18
+ getSession: () => ({
19
+ views: [lgv],
20
+ hovered: undefined,
21
+ rpcManager: { call: rpcCall },
22
+ }),
23
+ }));
24
+ vi.mock('@jbrowse/core/configuration', async (importOriginal) => ({
25
+ ...(await importOriginal()),
26
+ getConf: (track) => track.adapter,
27
+ }));
28
+ vi.mock('../LaunchMsaView/components/fetchSeq', () => ({
29
+ fetchSeq: vi.fn(async () => ({ seq: 'ATGAAAGTG' })),
30
+ }));
31
+ vi.mock('../LaunchMsaView/components/calculateProteinSequence', () => ({
32
+ getProteinSequenceFromFeature: vi.fn(() => 'MKV'),
33
+ }));
34
+ vi.mock('./doLaunchBlast', () => ({
35
+ doLaunchBlast: vi.fn(() => new Promise(() => { })),
36
+ }));
37
+ vi.mock('./msaDataStore', () => ({
38
+ cleanupOldData: vi.fn(async () => { }),
39
+ deleteMsaData: vi.fn(async () => { }),
40
+ generateDataStoreId: vi.fn(),
41
+ retrieveMsaData: vi.fn(),
42
+ storeMsaData: vi.fn(),
43
+ }));
44
+ function transcript(id) {
45
+ const json = { uniqueId: id, id, refName: 'chr17', start: 0, end: 9 };
46
+ return {
47
+ id: () => id,
48
+ get: (key) => (key === 'id' || key === 'name' ? id : undefined),
49
+ toJSON: () => json,
50
+ };
51
+ }
52
+ const settle = () => new Promise(res => setTimeout(res, 0));
53
+ function connectedView() {
54
+ return stateModelFactory().create({
55
+ type: 'MsaView',
56
+ id: 'msa1',
57
+ connectedViewId: 'lgv1',
58
+ connectedTranscript: 'NM_000546.6',
59
+ blastParams: {
60
+ searchProgram: 'blastp',
61
+ blastDatabase: 'uniprotkb_swissprot',
62
+ msaAlgorithm: 'clustalo',
63
+ },
64
+ });
65
+ }
66
+ beforeEach(() => {
67
+ rpcCall.mockReset();
68
+ lgv.tracks = [{ type: 'FeatureTrack', adapter: 'genes' }];
69
+ lgv.dynamicBlocks.contentBlocks = [];
70
+ vi.spyOn(console, 'error').mockImplementation(() => { });
71
+ });
72
+ describe('a lookup that misses', () => {
73
+ test('reports the miss without leaving its progress up', async () => {
74
+ rpcCall.mockResolvedValue([]);
75
+ const model = connectedView();
76
+ await settle();
77
+ expect(model.error).toBeInstanceOf(Error);
78
+ expect(model.progress).toBe('');
79
+ });
80
+ test('Retry looks again', async () => {
81
+ rpcCall.mockResolvedValue([]);
82
+ const model = connectedView();
83
+ await settle();
84
+ const calls = rpcCall.mock.calls.length;
85
+ rpcCall.mockResolvedValue([transcript('NM_000546.6')]);
86
+ model.retryLaunch();
87
+ await settle();
88
+ expect(rpcCall.mock.calls.length).toBeGreaterThan(calls);
89
+ expect(model.connectedFeature).toMatchObject({ id: 'NM_000546.6' });
90
+ expect(model.blastParams?.proteinSequence).toBe('MKV');
91
+ });
92
+ });
93
+ describe('where a lookup searches', () => {
94
+ test('feature tracks only, on screen before the whole chromosome', async () => {
95
+ rpcCall.mockResolvedValue([]);
96
+ lgv.tracks = [
97
+ { type: 'AlignmentsTrack', adapter: 'bam' },
98
+ { type: 'FeatureTrack', adapter: 'genes' },
99
+ ];
100
+ lgv.dynamicBlocks.contentBlocks = [
101
+ {
102
+ assemblyName: 'hg38',
103
+ refName: 'chr17',
104
+ start: 7_661_778.6,
105
+ end: 7_687_538.2,
106
+ },
107
+ ];
108
+ connectedView();
109
+ await settle();
110
+ const asked = rpcCall.mock.calls.map(([, , args]) => [
111
+ args.adapterConfig,
112
+ args.regions,
113
+ ]);
114
+ expect(asked).toEqual([
115
+ [
116
+ 'genes',
117
+ [
118
+ {
119
+ assemblyName: 'hg38',
120
+ refName: 'chr17',
121
+ start: 7_661_778,
122
+ end: 7_687_539,
123
+ },
124
+ ],
125
+ ],
126
+ ['genes', lgv.displayedRegions],
127
+ ]);
128
+ });
129
+ });
130
+ describe('two lookups in flight', () => {
131
+ test('only the later one writes', async () => {
132
+ const pending = [];
133
+ rpcCall.mockImplementation(() => new Promise(res => {
134
+ pending.push(res);
135
+ }));
136
+ const model = connectedView();
137
+ await settle();
138
+ model.retryLaunch();
139
+ await settle();
140
+ expect(pending).toHaveLength(2);
141
+ pending[1]([transcript('NM_000546.6')]);
142
+ await settle();
143
+ const written = model.connectedFeature;
144
+ pending[0]([transcript('NM_000546.5')]);
145
+ await settle();
146
+ expect(written).toMatchObject({ id: 'NM_000546.6' });
147
+ expect(model.connectedFeature).toBe(written);
148
+ });
149
+ });
@@ -26,11 +26,15 @@ interface LaunchedData {
26
26
  * Run one launch attempt, owning the AbortController that ties it to the view.
27
27
  *
28
28
  * The controller lives on the model because two things end a launch and neither
29
- * is here: the Cancel button, and the disposer `afterCreate` registers. Before
30
- * it existed, closing a view mid-BLAST left the poller checking EBI for the
31
- * job's lifetime and then writing to a node that was gone.
29
+ * is here: the Cancel button, and the disposer `afterCreate` registers. A launch
30
+ * already holding it is aborted first, so a re-fired autorun replaces the
31
+ * attempt rather than orphaning one that nothing can cancel.
32
+ *
33
+ * Runs untracked: it is called from the launch autoruns, and whatever the
34
+ * launch body reads before its first await would otherwise become a reason to
35
+ * launch again.
32
36
  */
33
- export declare function runLaunch({ self, message, launch, onLaunched, }: {
37
+ export declare function runLaunch(args: {
34
38
  self: JBrowsePluginMsaViewModel;
35
39
  message: string;
36
40
  launch: (scope: LaunchScope) => Promise<LaunchedData>;
@@ -1,4 +1,5 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { untracked } from 'mobx';
2
3
  import { isAbortError } from '../utils/fetch';
3
4
  /**
4
5
  * The in-browser aligner and a search hand back rows and no tree;
@@ -33,11 +34,21 @@ function buildTree(self) {
33
34
  * Run one launch attempt, owning the AbortController that ties it to the view.
34
35
  *
35
36
  * The controller lives on the model because two things end a launch and neither
36
- * is here: the Cancel button, and the disposer `afterCreate` registers. Before
37
- * it existed, closing a view mid-BLAST left the poller checking EBI for the
38
- * job's lifetime and then writing to a node that was gone.
37
+ * is here: the Cancel button, and the disposer `afterCreate` registers. A launch
38
+ * already holding it is aborted first, so a re-fired autorun replaces the
39
+ * attempt rather than orphaning one that nothing can cancel.
40
+ *
41
+ * Runs untracked: it is called from the launch autoruns, and whatever the
42
+ * launch body reads before its first await would otherwise become a reason to
43
+ * launch again.
39
44
  */
40
- export function runLaunch({ self, message, launch, onLaunched, }) {
45
+ export function runLaunch(args) {
46
+ untracked(() => {
47
+ startLaunch(args);
48
+ });
49
+ }
50
+ function startLaunch({ self, message, launch, onLaunched, }) {
51
+ self.launchController?.abort();
41
52
  const controller = new AbortController();
42
53
  const { signal } = controller;
43
54
  self.setLaunchController(controller);
@@ -112,6 +112,22 @@ describe('a launch that is abandoned', () => {
112
112
  expect(writes.some(w => w.startsWith('setError:Aborted'))).toBe(false);
113
113
  });
114
114
  });
115
+ describe('a launch started while another holds the controller', () => {
116
+ test('aborts the first, so nothing is left polling that Cancel cannot reach', async () => {
117
+ const { model } = makeModel();
118
+ const signals = [];
119
+ const launch = (scope) => {
120
+ signals.push(scope.signal);
121
+ return new Promise(() => { });
122
+ };
123
+ runLaunch({ self: model, message: 'a', onLaunched: () => { }, launch });
124
+ runLaunch({ self: model, message: 'b', onLaunched: () => { }, launch });
125
+ await settle();
126
+ expect(signals.map(s => s.aborted)).toEqual([true, false]);
127
+ model.launchController.abort();
128
+ expect(signals[1].aborted).toBe(true);
129
+ });
130
+ });
115
131
  describe('a launch that fails', () => {
116
132
  test('records the error and stops the spinner', async () => {
117
133
  vi.spyOn(console, 'error').mockImplementation(() => { });
@@ -49,6 +49,9 @@ function makeModel(over = {}) {
49
49
  setTreeMetadata(arg) {
50
50
  model.data.treeMetadata = arg;
51
51
  },
52
+ setGFF(arg) {
53
+ model.data.gff = arg;
54
+ },
52
55
  ...over,
53
56
  };
54
57
  return model;
@@ -82,6 +85,14 @@ describe('restoring a view from IndexedDB', () => {
82
85
  storeDataToIndexedDB(model);
83
86
  expect(mockStore).not.toHaveBeenCalled();
84
87
  });
88
+ // react-msaview drops a GFF over 50kB from the snapshot too
89
+ test('a stored GFF comes back with the alignment', async () => {
90
+ mockRetrieve.mockResolvedValue({ msa: MSA, gff: 'a\t.\tdomain\t1\t2' });
91
+ const model = makeModel({ dataStoreId: 'msa-1' });
92
+ loadStoredData(model);
93
+ await settle();
94
+ expect(model.data.gff).toBe('a\t.\tdomain\t1\t2');
95
+ });
85
96
  // the row expires or the user clears site data, and the view used to reopen as
86
97
  // a blank import form with no hint that anything had been lost
87
98
  test('a row that is gone says so, in words that name the policy', async () => {
@@ -147,6 +158,18 @@ describe('keeping IndexedDB up to date', () => {
147
158
  });
148
159
  expect(model.dataStoreId).toBe('msa-1');
149
160
  });
161
+ test('a GFF loaded after the first write updates the row', async () => {
162
+ const model = makeModel({
163
+ dataStoreId: 'msa-1',
164
+ rows: [['a', 'MK']],
165
+ data: { msa: MSA },
166
+ lastStoredData: { msa: MSA },
167
+ });
168
+ model.data.gff = 'a\t.\tdomain\t1\t2';
169
+ storeDataToIndexedDB(model);
170
+ await settle();
171
+ expect(mockStore).toHaveBeenCalledWith('msa-1', expect.objectContaining({ gff: 'a\t.\tdomain\t1\t2' }));
172
+ });
150
173
  // a browser that refuses IndexedDB (private mode) answers every write the same
151
174
  // way, and the autorun reruns on its own isStoringData flag -- so a write that
152
175
  // is not recorded as attempted is a write that retries forever
@@ -15,17 +15,18 @@ const mafRegion = {
15
15
  assemblyName: 'hg38',
16
16
  };
17
17
  // A model wired through the real genomeToMSA path: a connected genome view
18
- // over a maf region, with seqPosToVisibleCol as identity so the asserted
18
+ // over a maf region, with the column converters as identity so the asserted
19
19
  // column equals the ungapped offset into the region.
20
20
  function makeModel() {
21
21
  const calls = [];
22
22
  const model = {
23
23
  querySeqName: 'hg38.chr1',
24
- rows: [['hg38.chr1', 'ACGTACGTAC']],
24
+ rowMap: new Map([['hg38.chr1', 'ACGTACGTAC']]),
25
25
  transcriptToMsaMap: undefined,
26
26
  mafRegion,
27
27
  connectedView: { initialized: true, assemblyNames: ['hg38'] },
28
28
  seqPosToVisibleCol: (_name, pos) => pos,
29
+ visibleColToSeqPos: (_name, col) => col,
29
30
  setMousePos: (col) => {
30
31
  calls.push(col);
31
32
  },
@@ -1,19 +1,14 @@
1
1
  /**
2
- * Whether `querySeqName` names a row this alignment actually has.
2
+ * Whether `querySeqName` names a row this alignment actually has. The name
3
+ * defaults to `QUERY`, which an uploaded alignment has no reason to carry, so
4
+ * the genome and structure hovers ask this before mapping anything.
3
5
  *
4
- * react-msaview's `seqPosToGlobalCol` answers 0 for a name it does not know, so
5
- * without this every genome position maps to the first column and hovering the
6
- * genome — or a connected structure — lights column 0 of an unrelated row. The
7
- * name is wrong more often than it looks: it defaults to `QUERY`, which an
8
- * uploaded alignment has no reason to carry, and the manual panel leaves it
9
- * empty when it cannot match the protein to a row.
10
- *
11
- * The other direction has no such hole: msaCoordToGenomeRegions needs the query
12
- * row's sequence to map a column at all, so a missing row is already nothing
13
- * there.
6
+ * `rowMap`, not `rows`: `rows` is what is on screen, and a query row folded
7
+ * into a collapsed clade is still in the alignment, still has its columns, and
8
+ * still maps.
14
9
  */
15
10
  export declare function hasQueryRow(model: {
16
- rows: string[][];
11
+ rowMap: ReadonlyMap<string, string>;
17
12
  querySeqName: string;
18
13
  }): boolean;
19
14
  /**
@@ -35,23 +30,28 @@ interface LiveFeature {
35
30
  export declare function transcriptFields(transcript?: TranscriptRef): Record<string, unknown>;
36
31
  /** what a transcript is called, preferring its name over its id */
37
32
  export declare function transcriptName(transcript?: TranscriptRef): string | undefined;
38
- export interface QueryRowModel {
33
+ interface QueryRowColumns {
39
34
  querySeqName: string;
40
- querySeqOffset: number;
41
35
  seqPosToVisibleCol: (rowName: string, seqPos: number) => number | undefined;
42
36
  visibleColToSeqPos: (rowName: string, visibleCol: number) => number | undefined;
43
37
  }
38
+ export interface QueryRowModel extends QueryRowColumns {
39
+ querySeqOffset: number;
40
+ }
44
41
  /**
45
- * The visible column showing residue `proteinPos` (0-based) of the transcript,
46
- * or undefined when the query row does not carry that residue or react-msaview
47
- * is hiding its column.
42
+ * The visible column showing residue `seqPos` (0-based) of the query row, or
43
+ * undefined when the row does not carry that residue or react-msaview is
44
+ * hiding its column.
48
45
  *
49
- * Two things separate the transcript from the row. `querySeqOffset` is the
50
- * trimming — a pasted BLAST alignment carries the aligned region, not the whole
51
- * protein. And the row can simply stop short: react-msaview answers one column
52
- * past the end for a position it does not have, which would light the last
53
- * column for every residue beyond the row, so the round trip back through
54
- * visibleColToSeqPos is what rejects those.
46
+ * react-msaview answers one column past the end for a position the row does not
47
+ * have, which would light the last column for every residue beyond the row, so
48
+ * the round trip back through visibleColToSeqPos is what rejects those.
49
+ */
50
+ export declare function querySeqPosToVisibleCol(model: QueryRowColumns, seqPos: number): number | undefined;
51
+ /**
52
+ * The visible column showing residue `proteinPos` (0-based) of the transcript.
53
+ * `querySeqOffset` is what separates the two: a pasted BLAST alignment carries
54
+ * the aligned region, not the whole protein.
55
55
  */
56
56
  export declare function transcriptPosToVisibleCol(model: QueryRowModel, proteinPos: number): number | undefined;
57
57
  export declare function hasHoverPosition(hovered: unknown): hovered is {
@@ -1,19 +1,14 @@
1
1
  /**
2
- * Whether `querySeqName` names a row this alignment actually has.
2
+ * Whether `querySeqName` names a row this alignment actually has. The name
3
+ * defaults to `QUERY`, which an uploaded alignment has no reason to carry, so
4
+ * the genome and structure hovers ask this before mapping anything.
3
5
  *
4
- * react-msaview's `seqPosToGlobalCol` answers 0 for a name it does not know, so
5
- * without this every genome position maps to the first column and hovering the
6
- * genome — or a connected structure — lights column 0 of an unrelated row. The
7
- * name is wrong more often than it looks: it defaults to `QUERY`, which an
8
- * uploaded alignment has no reason to carry, and the manual panel leaves it
9
- * empty when it cannot match the protein to a row.
10
- *
11
- * The other direction has no such hole: msaCoordToGenomeRegions needs the query
12
- * row's sequence to map a column at all, so a missing row is already nothing
13
- * there.
6
+ * `rowMap`, not `rows`: `rows` is what is on screen, and a query row folded
7
+ * into a collapsed clade is still in the alignment, still has its columns, and
8
+ * still maps.
14
9
  */
15
10
  export function hasQueryRow(model) {
16
- return model.rows.some(r => r[0] === model.querySeqName);
11
+ return model.rowMap.has(model.querySeqName);
17
12
  }
18
13
  /**
19
14
  * Read the transcript's fields whichever shape it arrived in: a caller inside
@@ -36,20 +31,16 @@ export function transcriptName(transcript) {
36
31
  return str(fields.name) ?? str(fields.id);
37
32
  }
38
33
  /**
39
- * The visible column showing residue `proteinPos` (0-based) of the transcript,
40
- * or undefined when the query row does not carry that residue or react-msaview
41
- * is hiding its column.
34
+ * The visible column showing residue `seqPos` (0-based) of the query row, or
35
+ * undefined when the row does not carry that residue or react-msaview is
36
+ * hiding its column.
42
37
  *
43
- * Two things separate the transcript from the row. `querySeqOffset` is the
44
- * trimming — a pasted BLAST alignment carries the aligned region, not the whole
45
- * protein. And the row can simply stop short: react-msaview answers one column
46
- * past the end for a position it does not have, which would light the last
47
- * column for every residue beyond the row, so the round trip back through
48
- * visibleColToSeqPos is what rejects those.
38
+ * react-msaview answers one column past the end for a position the row does not
39
+ * have, which would light the last column for every residue beyond the row, so
40
+ * the round trip back through visibleColToSeqPos is what rejects those.
49
41
  */
50
- export function transcriptPosToVisibleCol(model, proteinPos) {
51
- const { querySeqName, querySeqOffset } = model;
52
- const seqPos = proteinPos - querySeqOffset;
42
+ export function querySeqPosToVisibleCol(model, seqPos) {
43
+ const { querySeqName } = model;
53
44
  if (seqPos < 0) {
54
45
  return undefined;
55
46
  }
@@ -59,6 +50,14 @@ export function transcriptPosToVisibleCol(model, proteinPos) {
59
50
  ? col
60
51
  : undefined;
61
52
  }
53
+ /**
54
+ * The visible column showing residue `proteinPos` (0-based) of the transcript.
55
+ * `querySeqOffset` is what separates the two: a pasted BLAST alignment carries
56
+ * the aligned region, not the whole protein.
57
+ */
58
+ export function transcriptPosToVisibleCol(model, proteinPos) {
59
+ return querySeqPosToVisibleCol(model, proteinPos - model.querySeqOffset);
60
+ }
62
61
  export function hasHoverPosition(hovered) {
63
62
  return (!!hovered &&
64
63
  typeof hovered === 'object' &&