jbrowse-plugin-msaview 3.0.0 → 3.2.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 (67) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +1 -1
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.d.ts +11 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +73 -16
  4. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +6 -4
  5. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +8 -1
  6. package/dist/LaunchMsaView/components/BlastQuery/consts.js +8 -1
  7. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +8 -15
  8. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +4 -2
  9. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +1 -1
  10. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +4 -2
  11. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +1 -1
  12. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +4 -2
  13. package/dist/LaunchMsaView/components/QueryRowSelector.d.ts +16 -0
  14. package/dist/LaunchMsaView/components/QueryRowSelector.js +38 -0
  15. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +4 -1
  16. package/dist/LaunchMsaView/components/SubmitCancelActions.js +34 -10
  17. package/dist/LaunchMsaView/components/SubmitCancelActions.test.d.ts +1 -0
  18. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +55 -0
  19. package/dist/LaunchMsaView/detectQueryRow.d.ts +23 -0
  20. package/dist/LaunchMsaView/detectQueryRow.js +94 -0
  21. package/dist/LaunchMsaView/detectQueryRow.test.d.ts +1 -0
  22. package/dist/LaunchMsaView/detectQueryRow.test.js +65 -0
  23. package/dist/LaunchMsaView/useQueryRowName.d.ts +15 -0
  24. package/dist/LaunchMsaView/useQueryRowName.js +26 -0
  25. package/dist/LaunchMsaViewExtensionPoint/index.js +2 -2
  26. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +27 -11
  27. package/dist/MsaViewPanel/afterCreateAutoruns.js +96 -47
  28. package/dist/MsaViewPanel/observeProteinHighlights.test.d.ts +1 -0
  29. package/dist/MsaViewPanel/observeProteinHighlights.test.js +209 -0
  30. package/dist/MsaViewPanel/structureConnection.d.ts +6 -0
  31. package/dist/index.js +4 -1
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js +28 -28
  33. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  34. package/dist/utils/launchMsaView.d.ts +19 -0
  35. package/dist/utils/launchMsaView.js +13 -0
  36. package/dist/utils/workspaces.d.ts +34 -0
  37. package/dist/utils/workspaces.js +100 -0
  38. package/dist/utils/workspaces.test.d.ts +1 -0
  39. package/dist/utils/workspaces.test.js +100 -0
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/package.json +6 -1
  43. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +1 -0
  44. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +127 -30
  45. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +7 -4
  46. package/src/LaunchMsaView/components/BlastQuery/consts.ts +8 -1
  47. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +8 -37
  48. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +5 -2
  49. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +1 -1
  50. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +5 -2
  51. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +1 -0
  52. package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +5 -2
  53. package/src/LaunchMsaView/components/QueryRowSelector.tsx +93 -0
  54. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +95 -0
  55. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +80 -22
  56. package/src/LaunchMsaView/detectQueryRow.test.ts +79 -0
  57. package/src/LaunchMsaView/detectQueryRow.ts +132 -0
  58. package/src/LaunchMsaView/useQueryRowName.ts +33 -0
  59. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -2
  60. package/src/MsaViewPanel/afterCreateAutoruns.ts +106 -51
  61. package/src/MsaViewPanel/observeProteinHighlights.test.ts +264 -0
  62. package/src/MsaViewPanel/structureConnection.ts +7 -0
  63. package/src/index.ts +4 -1
  64. package/src/utils/launchMsaView.ts +30 -0
  65. package/src/utils/workspaces.test.ts +132 -0
  66. package/src/utils/workspaces.ts +146 -0
  67. package/src/version.ts +1 -1
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { detectQueryRow, getMsaRowNames } from './detectQueryRow';
3
+ const protein = 'MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD';
4
+ // COBALT renames the query `Query_1`, so only the residues identify it
5
+ const clustal = `CLUSTAL W (1.81) multiple sequence alignment
6
+
7
+ Query_1 MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
8
+ sp|P02769|ALBU MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPYD
9
+ sp|Q5XLE4|OTHE MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLWWCPFD
10
+ `;
11
+ const fasta = `>Query_1
12
+ MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
13
+ >sp|P02769|ALBU_BOVIN
14
+ MKWVTFISLLLLFSSAYSRG--RRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPYD
15
+ `;
16
+ describe('detectQueryRow', () => {
17
+ test('finds the query by sequence when the aligner renamed it', () => {
18
+ expect(detectQueryRow(clustal, protein)).toMatchObject({
19
+ name: 'Query_1',
20
+ quality: 'exact',
21
+ });
22
+ });
23
+ test('ignores gaps in the aligned row', () => {
24
+ expect(detectQueryRow(fasta, protein)?.name).toBe('Query_1');
25
+ });
26
+ test('tolerates the trailing stop codon the translation carries', () => {
27
+ expect(detectQueryRow(clustal, `${protein}*`)?.name).toBe('Query_1');
28
+ });
29
+ test('matches a row that is the query trimmed to the aligned region', () => {
30
+ const trimmed = `>hit_one\nWRONGWRONGWRONGWRONG\n>aligned_query\n${protein.slice(5, 40)}\n`;
31
+ expect(detectQueryRow(trimmed, protein)).toMatchObject({
32
+ name: 'aligned_query',
33
+ quality: 'partial',
34
+ });
35
+ });
36
+ // the failure that matters: silently wiring the view to a homolog would look
37
+ // like it worked, and every navigation afterwards would land in the wrong place
38
+ test('returns nothing when only diverged homologs are present', () => {
39
+ const homologsOnly = `>hit_one
40
+ MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
41
+ >hit_two
42
+ MKWVTFISLLLLFSSAYSRGVFRRDTHKSEIAHRFKDLGEEHFKGLVLIAFSQYLQQCPFD
43
+ `;
44
+ expect(detectQueryRow(homologsOnly, 'WWWWWWWWWWWWWWWWWWWWWWWWWWWWWW')).toBeUndefined();
45
+ });
46
+ test('returns nothing rather than throwing on a half-pasted alignment', () => {
47
+ expect(detectQueryRow('>partial\nMKWV', protein)).toBeUndefined();
48
+ expect(detectQueryRow('not an alignment at all', protein)).toBeUndefined();
49
+ expect(detectQueryRow('', protein)).toBeUndefined();
50
+ expect(detectQueryRow(clustal, '')).toBeUndefined();
51
+ });
52
+ });
53
+ describe('getMsaRowNames', () => {
54
+ test('lists the rows for the override dropdown', () => {
55
+ expect(getMsaRowNames(clustal)).toEqual([
56
+ 'Query_1',
57
+ 'sp|P02769|ALBU',
58
+ 'sp|Q5XLE4|OTHE',
59
+ ]);
60
+ });
61
+ test('is empty rather than throwing while the user is still pasting', () => {
62
+ expect(getMsaRowNames('CLUSTAL W')).toEqual([]);
63
+ expect(getMsaRowNames('')).toEqual([]);
64
+ });
65
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The MSA row name to launch with, found by sequence rather than typed.
3
+ *
4
+ * Only the user's override is state. The detected name is derived from the
5
+ * pasted text during render, so pasting a new alignment re-detects without an
6
+ * effect writing back into state, and an override survives later edits to the
7
+ * alignment because it is the one thing actually stored.
8
+ */
9
+ export declare function useQueryRowName(msaText: string, proteinSequence: string): {
10
+ detected: import("./detectQueryRow").QueryRowMatch | undefined;
11
+ names: string[];
12
+ querySeqName: string;
13
+ setQuerySeqName: import("react").Dispatch<import("react").SetStateAction<string | undefined>>;
14
+ isAutoDetected: boolean;
15
+ };
@@ -0,0 +1,26 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { detectQueryRow, getMsaRowNames } from './detectQueryRow';
3
+ /**
4
+ * The MSA row name to launch with, found by sequence rather than typed.
5
+ *
6
+ * Only the user's override is state. The detected name is derived from the
7
+ * pasted text during render, so pasting a new alignment re-detects without an
8
+ * effect writing back into state, and an override survives later edits to the
9
+ * alignment because it is the one thing actually stored.
10
+ */
11
+ export function useQueryRowName(msaText, proteinSequence) {
12
+ const [override, setOverride] = useState();
13
+ // parsing runs on every keystroke in the paste box otherwise, and an
14
+ // alignment of a few hundred rows is not free
15
+ const { detected, names } = useMemo(() => ({
16
+ detected: detectQueryRow(msaText, proteinSequence),
17
+ names: getMsaRowNames(msaText),
18
+ }), [msaText, proteinSequence]);
19
+ return {
20
+ detected,
21
+ names,
22
+ querySeqName: override ?? detected?.name ?? '',
23
+ setQuerySeqName: setOverride,
24
+ isAutoDetected: override === undefined && !!detected,
25
+ };
26
+ }
@@ -1,3 +1,4 @@
1
+ import { launchMsaView } from '../utils/launchMsaView';
1
2
  export default function LaunchMsaViewExtensionPointF(pluginManager) {
2
3
  pluginManager.addToExtensionPoint('LaunchView-MsaView', (args) => {
3
4
  const { session, data, msaFileLocation, msaIndexedLocation, msaName, treeFileLocation, querySeqName, ...rest } = args;
@@ -14,8 +15,7 @@ export default function LaunchMsaViewExtensionPointF(pluginManager) {
14
15
  // directly, and so is orthologParams (the model's own autorun picks it up).
15
16
  // Only sources needing launch-time resolution go through `init`: msaUrl
16
17
  // (AlphaFold sniff) and the name-indexed bgzip block (no native loader).
17
- session.addView('MsaView', {
18
- type: 'MsaView',
18
+ launchMsaView(session, {
19
19
  ...rest,
20
20
  data,
21
21
  ...(treeFileLocation
@@ -27,18 +27,34 @@ export declare function processInit(self: JBrowsePluginMsaViewModel): void;
27
27
  */
28
28
  export declare function syncGenomeHoverToMsaColumn(self: JBrowsePluginMsaViewModel): () => void;
29
29
  /**
30
- * Mirror a connected 3D protein view's hovered residue onto the MSA's
31
- * highlighted columns. Returns the autorun body and keeps a flag tracking
32
- * whether the current highlight was set by THIS sync: when a protein hover ends
33
- * we restore the declarative highlightColumns seed (or clear) rather than
34
- * blindly wiping it.
30
+ * Mirror a connected 3D protein view's highlights onto the MSA's highlighted
31
+ * columns, from either of the two channels protein3d publishes:
35
32
  *
36
- * Without the flag this autorun fires once on creation — with the view connected
37
- * to a *genome* LGV but no 3D protein structure attached — computes zero columns,
38
- * and calls setHighlightedColumns(undefined), clobbering the seed that
39
- * MSAModelF.afterCreate just set from the declarative `highlightColumns`. That is
40
- * the bug that made the BRAF/TP53 genome-browser links open with no V600/R248
41
- * column lit (SRC has no highlightColumns, so nothing was there to wipe).
33
+ * - `hoverGenomeHighlights` — the residue under the pointer, transient.
34
+ * - `clickGenomeHighlights` — the domain the user clicked, persistent. Also
35
+ * what protein3d's declarative `initialSelection` lights on load, so a session
36
+ * spec that pre-selects a domain in the structure now lands in the alignment
37
+ * too, instead of the caller having to author the same range a second time as
38
+ * the MSA's own `highlightColumns`.
39
+ *
40
+ * Highest-priority non-empty source wins: a hover reads as a transient probe on
41
+ * top of the standing selection, and letting it win means moving the pointer
42
+ * over the structure previews a residue without destroying what was selected.
43
+ * Releasing the hover falls back to the click selection, then to the declarative
44
+ * `highlightColumns` seed.
45
+ *
46
+ * Resolving the seed as the last rung of that stack is what replaced a
47
+ * `proteinDriven` flag this function used to carry. The flag existed because the
48
+ * body could not otherwise tell "no protein highlight, leave the seed alone"
49
+ * from "the protein highlight ended, restore the seed", and getting that wrong
50
+ * wiped the seed on the very first run — the bug that made the BRAF/TP53
51
+ * genome-browser links open with no V600/R248 column lit. Now every source is in
52
+ * one expression, so the result depends only on what the sources currently say
53
+ * and there is no ordering to get wrong.
54
+ *
55
+ * A closure remains, but it decides nothing: `written` only suppresses a
56
+ * redundant redraw. Delete it and the highlight is identical, just recomputed
57
+ * more often — where deleting the old flag changed which columns lit.
42
58
  */
43
59
  export declare function observeProteinHighlights(self: JBrowsePluginMsaViewModel): () => void;
44
60
  export declare function runCleanup(): void;
@@ -212,60 +212,109 @@ export function syncGenomeHoverToMsaColumn(self) {
212
212
  };
213
213
  }
214
214
  /**
215
- * Mirror a connected 3D protein view's hovered residue onto the MSA's
216
- * highlighted columns. Returns the autorun body and keeps a flag tracking
217
- * whether the current highlight was set by THIS sync: when a protein hover ends
218
- * we restore the declarative highlightColumns seed (or clear) rather than
219
- * blindly wiping it.
215
+ * Translate genome regions published by a 3D protein view into this MSA's
216
+ * visible columns. The genome is the only coordinate space the two plugins
217
+ * share, so the hops are genome coord -> protein position (the transcript's g2p
218
+ * map) -> global alignment column -> visible column.
219
+ */
220
+ function genomeHighlightsToVisibleColumns(self, field) {
221
+ const { connectedViewId, transcriptToMsaMap, querySeqName } = self;
222
+ if (!transcriptToMsaMap) {
223
+ return [];
224
+ }
225
+ const { g2p } = transcriptToMsaMap;
226
+ const columns = new Set();
227
+ for (const view of getProteinViews(getSession(self).views)) {
228
+ for (const structure of view.structures) {
229
+ if (structure.connectedViewId !== connectedViewId) {
230
+ continue;
231
+ }
232
+ for (const highlight of structure[field] ?? []) {
233
+ for (let coord = highlight.start; coord < highlight.end; coord++) {
234
+ const proteinPos = g2p[coord];
235
+ if (proteinPos !== undefined) {
236
+ columns.add(self.seqPosToGlobalCol(querySeqName, proteinPos));
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return [...columns]
243
+ .map(col => self.globalColToVisibleCol(col))
244
+ .filter((col) => col !== undefined);
245
+ }
246
+ function sameColumns(a, b) {
247
+ if (!a || !b) {
248
+ return a === b;
249
+ }
250
+ return a.length === b.length && a.every((col, i) => col === b[i]);
251
+ }
252
+ /**
253
+ * Mirror a connected 3D protein view's highlights onto the MSA's highlighted
254
+ * columns, from either of the two channels protein3d publishes:
255
+ *
256
+ * - `hoverGenomeHighlights` — the residue under the pointer, transient.
257
+ * - `clickGenomeHighlights` — the domain the user clicked, persistent. Also
258
+ * what protein3d's declarative `initialSelection` lights on load, so a session
259
+ * spec that pre-selects a domain in the structure now lands in the alignment
260
+ * too, instead of the caller having to author the same range a second time as
261
+ * the MSA's own `highlightColumns`.
262
+ *
263
+ * Highest-priority non-empty source wins: a hover reads as a transient probe on
264
+ * top of the standing selection, and letting it win means moving the pointer
265
+ * over the structure previews a residue without destroying what was selected.
266
+ * Releasing the hover falls back to the click selection, then to the declarative
267
+ * `highlightColumns` seed.
220
268
  *
221
- * Without the flag this autorun fires once on creation — with the view connected
222
- * to a *genome* LGV but no 3D protein structure attached — computes zero columns,
223
- * and calls setHighlightedColumns(undefined), clobbering the seed that
224
- * MSAModelF.afterCreate just set from the declarative `highlightColumns`. That is
225
- * the bug that made the BRAF/TP53 genome-browser links open with no V600/R248
226
- * column lit (SRC has no highlightColumns, so nothing was there to wipe).
269
+ * Resolving the seed as the last rung of that stack is what replaced a
270
+ * `proteinDriven` flag this function used to carry. The flag existed because the
271
+ * body could not otherwise tell "no protein highlight, leave the seed alone"
272
+ * from "the protein highlight ended, restore the seed", and getting that wrong
273
+ * wiped the seed on the very first run — the bug that made the BRAF/TP53
274
+ * genome-browser links open with no V600/R248 column lit. Now every source is in
275
+ * one expression, so the result depends only on what the sources currently say
276
+ * and there is no ordering to get wrong.
277
+ *
278
+ * A closure remains, but it decides nothing: `written` only suppresses a
279
+ * redundant redraw. Delete it and the highlight is identical, just recomputed
280
+ * more often — where deleting the old flag changed which columns lit.
227
281
  */
228
282
  export function observeProteinHighlights(self) {
229
- let proteinDriven = false;
283
+ // The columns this reaction last wrote, kept to skip a write that would not
284
+ // change anything: protein3d recomputes hoverGenomeHighlights on every mouse
285
+ // move over the structure, and moving within one codon yields a fresh array of
286
+ // the same columns, which would redraw the overlay canvas for nothing.
287
+ //
288
+ // Deliberately a closure rather than a read of `self.highlightedColumns` --
289
+ // reading it would put this reaction's own output in its dependency set, so
290
+ // every write would re-trigger it. It converges, but the dependencies should be
291
+ // the sources the highlight derives FROM, not the highlight itself.
292
+ let written;
230
293
  return () => {
231
- const { connectedViewId, transcriptToMsaMap, querySeqName } = self;
294
+ const { connectedViewId, transcriptToMsaMap } = self;
232
295
  if (!connectedViewId || !transcriptToMsaMap) {
233
296
  return;
234
297
  }
235
- const columns = new Set();
236
- for (const view of getProteinViews(getSession(self).views)) {
237
- for (const structure of view.structures) {
238
- if (structure.connectedViewId !== connectedViewId) {
239
- continue;
240
- }
241
- const highlights = structure.hoverGenomeHighlights;
242
- if (!highlights || highlights.length === 0) {
243
- continue;
244
- }
245
- const { g2p } = transcriptToMsaMap;
246
- for (const highlight of highlights) {
247
- for (let coord = highlight.start; coord < highlight.end; coord++) {
248
- const proteinPos = g2p[coord];
249
- if (proteinPos !== undefined) {
250
- const col = self.seqPosToGlobalCol(querySeqName, proteinPos);
251
- columns.add(col);
252
- }
253
- }
254
- }
255
- }
256
- }
257
- const visibleColumns = Array.from(columns)
258
- .map(col => self.globalColToVisibleCol(col))
259
- .filter((col) => col !== undefined);
260
- if (visibleColumns.length > 0) {
261
- self.setHighlightedColumns(visibleColumns);
262
- proteinDriven = true;
263
- }
264
- else if (proteinDriven) {
265
- // our protein-hover highlight ended — fall back to the declarative seed
266
- // instead of wiping a column the URL/user asked to keep lit
267
- self.setHighlightedColumns(self.highlightColumns?.length ? self.highlightColumns : undefined);
268
- proteinDriven = false;
298
+ const hover = genomeHighlightsToVisibleColumns(self, 'hoverGenomeHighlights');
299
+ // Skipping the click channel while hovering is worth the subtlety it costs:
300
+ // a hover recomputes on every mouse move, and a clicked domain can be
301
+ // hundreds of residues, so translating a selection that cannot win would
302
+ // walk thousands of genome coordinates per pointer move.
303
+ //
304
+ // The subtlety is that not reading clickGenomeHighlights leaves it out of
305
+ // this reaction's dependencies until the hover clears. Changing the
306
+ // selection mid-hover therefore does not re-run us -- which is harmless,
307
+ // because the hover would have outranked it anyway, and releasing the hover
308
+ // re-runs and picks up whatever the selection now says.
309
+ const click = hover.length
310
+ ? []
311
+ : genomeHighlightsToVisibleColumns(self, 'clickGenomeHighlights');
312
+ const seed = self.highlightColumns ?? [];
313
+ const winner = hover.length ? hover : click.length ? click : seed;
314
+ const next = winner.length > 0 ? winner : undefined;
315
+ if (!sameColumns(written, next)) {
316
+ written = next;
317
+ self.setHighlightedColumns(next);
269
318
  }
270
319
  };
271
320
  }
@@ -0,0 +1,209 @@
1
+ import { getSession } from '@jbrowse/core/util';
2
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
3
+ import { observeProteinHighlights } from './afterCreateAutoruns';
4
+ // Mock only getSession; keep the rest of the util module real so the
5
+ // afterCreateAutoruns import graph still loads.
6
+ vi.mock('@jbrowse/core/util', async (importOriginal) => ({
7
+ ...(await importOriginal()),
8
+ getSession: vi.fn(),
9
+ }));
10
+ const mockGetSession = vi.mocked(getSession);
11
+ const CONNECTED = 'lgv-1';
12
+ /**
13
+ * A model with an identity genome->protein->column mapping, so an asserted
14
+ * column equals the genome coordinate that produced it and the test reads as
15
+ * "these genome coords lit these columns".
16
+ */
17
+ function makeModel({ highlightColumns } = {}) {
18
+ const calls = [];
19
+ const model = {
20
+ querySeqName: 'query',
21
+ connectedViewId: CONNECTED,
22
+ // g2p is indexed by genome coord; identity keeps the arithmetic out of the way
23
+ transcriptToMsaMap: {
24
+ g2p: Object.fromEntries([...Array(200).keys()].map(i => [i, i])),
25
+ },
26
+ highlightColumns,
27
+ highlightedColumns: undefined,
28
+ seqPosToGlobalCol: (_name, pos) => pos,
29
+ globalColToVisibleCol: (col) => col,
30
+ setHighlightedColumns: (cols) => {
31
+ calls.push(cols);
32
+ model.highlightedColumns = cols;
33
+ },
34
+ };
35
+ return { model, calls };
36
+ }
37
+ /** publish highlight channels on a ProteinView structure in the session */
38
+ function session({ hover, click, connectedViewId = CONNECTED, }) {
39
+ mockGetSession.mockReturnValue({
40
+ views: [
41
+ {
42
+ type: 'ProteinView',
43
+ id: 'pv-1',
44
+ structures: [
45
+ {
46
+ connectedViewId,
47
+ hoverGenomeHighlights: hover,
48
+ clickGenomeHighlights: click,
49
+ },
50
+ ],
51
+ },
52
+ ],
53
+ });
54
+ }
55
+ function noProteinView() {
56
+ mockGetSession.mockReturnValue({
57
+ views: [{ type: 'LinearGenomeView', id: CONNECTED }],
58
+ });
59
+ }
60
+ beforeEach(() => {
61
+ vi.clearAllMocks();
62
+ });
63
+ describe('the hover channel', () => {
64
+ test('a hovered residue lights its column', () => {
65
+ const { model, calls } = makeModel();
66
+ const run = observeProteinHighlights(model);
67
+ session({ hover: [{ start: 10, end: 13 }] });
68
+ run();
69
+ expect(calls).toEqual([[10, 11, 12]]);
70
+ });
71
+ test('releasing the hover clears the highlight', () => {
72
+ const { model, calls } = makeModel();
73
+ const run = observeProteinHighlights(model);
74
+ session({ hover: [{ start: 10, end: 12 }] });
75
+ run();
76
+ session({ hover: [] });
77
+ run();
78
+ expect(calls).toEqual([[10, 11], undefined]);
79
+ });
80
+ });
81
+ describe('the click channel', () => {
82
+ test('a clicked domain lights its columns', () => {
83
+ const { model, calls } = makeModel();
84
+ const run = observeProteinHighlights(model);
85
+ session({ click: [{ start: 30, end: 34 }] });
86
+ run();
87
+ expect(calls).toEqual([[30, 31, 32, 33]]);
88
+ });
89
+ test('a hover wins over the standing click selection', () => {
90
+ const { model, calls } = makeModel();
91
+ const run = observeProteinHighlights(model);
92
+ session({ click: [{ start: 30, end: 32 }], hover: [{ start: 5, end: 6 }] });
93
+ run();
94
+ expect(calls).toEqual([[5]]);
95
+ });
96
+ test('a selection changed during a hover is picked up when the hover releases', () => {
97
+ const { model, calls } = makeModel();
98
+ const run = observeProteinHighlights(model);
99
+ // the reaction skips the click channel while hovering, so it is not watching
100
+ // it; this pins that releasing the hover still lands on the CURRENT selection
101
+ // rather than on the one that was standing when the hover began
102
+ session({ click: [{ start: 30, end: 32 }], hover: [{ start: 5, end: 6 }] });
103
+ run();
104
+ session({ click: [{ start: 60, end: 62 }], hover: [{ start: 5, end: 6 }] });
105
+ run();
106
+ session({ click: [{ start: 60, end: 62 }] });
107
+ run();
108
+ expect(calls).toEqual([[5], [60, 61]]);
109
+ });
110
+ test('releasing the hover falls back to the click selection, not to nothing', () => {
111
+ const { model, calls } = makeModel();
112
+ const run = observeProteinHighlights(model);
113
+ // this is the whole point of the two channels: previewing a residue must not
114
+ // destroy the domain the user selected
115
+ session({ click: [{ start: 30, end: 32 }] });
116
+ run();
117
+ session({ click: [{ start: 30, end: 32 }], hover: [{ start: 5, end: 6 }] });
118
+ run();
119
+ session({ click: [{ start: 30, end: 32 }] });
120
+ run();
121
+ expect(calls).toEqual([[30, 31], [5], [30, 31]]);
122
+ });
123
+ });
124
+ describe('the declarative highlightColumns seed', () => {
125
+ // the regression these guard: the observer used to compute zero columns on its
126
+ // first run and wipe the seed MSAModelF.afterCreate had just applied, which is
127
+ // what made the BRAF/TP53 links open with no V600/R248 column lit
128
+ test('a first run with no protein view leaves the seed alone', () => {
129
+ const { model, calls } = makeModel({ highlightColumns: [77] });
130
+ const run = observeProteinHighlights(model);
131
+ noProteinView();
132
+ run();
133
+ expect(calls).toEqual([[77]]);
134
+ expect(model.highlightedColumns).toEqual([77]);
135
+ });
136
+ test('repeated runs never clobber the seed', () => {
137
+ const { model } = makeModel({ highlightColumns: [77] });
138
+ const run = observeProteinHighlights(model);
139
+ noProteinView();
140
+ run();
141
+ run();
142
+ run();
143
+ expect(model.highlightedColumns).toEqual([77]);
144
+ });
145
+ test('a hover overrides the seed, and releasing it restores the seed', () => {
146
+ const { model, calls } = makeModel({ highlightColumns: [77] });
147
+ const run = observeProteinHighlights(model);
148
+ session({ hover: [{ start: 1, end: 2 }] });
149
+ run();
150
+ session({ hover: [] });
151
+ run();
152
+ expect(calls).toEqual([[1], [77]]);
153
+ });
154
+ test('a click selection outranks the seed', () => {
155
+ const { model, calls } = makeModel({ highlightColumns: [77] });
156
+ const run = observeProteinHighlights(model);
157
+ session({ click: [{ start: 40, end: 42 }] });
158
+ run();
159
+ expect(calls).toEqual([[40, 41]]);
160
+ });
161
+ test('with no seed and no protein highlight, nothing is written at all', () => {
162
+ const { model, calls } = makeModel();
163
+ const run = observeProteinHighlights(model);
164
+ noProteinView();
165
+ run();
166
+ run();
167
+ expect(calls).toEqual([]);
168
+ });
169
+ });
170
+ describe('scope and redundant writes', () => {
171
+ test('a structure connected to a different view is ignored', () => {
172
+ const { model, calls } = makeModel();
173
+ const run = observeProteinHighlights(model);
174
+ session({
175
+ hover: [{ start: 10, end: 12 }],
176
+ connectedViewId: 'some-other-view',
177
+ });
178
+ run();
179
+ expect(calls).toEqual([]);
180
+ });
181
+ test('an unchanged highlight is not rewritten, so the overlay does not redraw', () => {
182
+ const { model, calls } = makeModel();
183
+ const run = observeProteinHighlights(model);
184
+ session({ hover: [{ start: 10, end: 12 }] });
185
+ run();
186
+ run();
187
+ run();
188
+ expect(calls).toEqual([[10, 11]]);
189
+ });
190
+ test('a genome coord with no protein position contributes no column', () => {
191
+ const { model, calls } = makeModel();
192
+ const run = observeProteinHighlights(model);
193
+ // 500 is past the end of the identity g2p map built above
194
+ session({ hover: [{ start: 500, end: 503 }] });
195
+ run();
196
+ expect(calls).toEqual([]);
197
+ });
198
+ test('nothing happens until the view is connected and mapped', () => {
199
+ const { calls } = makeModel();
200
+ const bare = {
201
+ connectedViewId: undefined,
202
+ transcriptToMsaMap: undefined,
203
+ };
204
+ const run = observeProteinHighlights(bare);
205
+ session({ hover: [{ start: 10, end: 12 }] });
206
+ run();
207
+ expect(calls).toEqual([]);
208
+ });
209
+ });
@@ -3,10 +3,16 @@ export interface ProteinViewStructure {
3
3
  connectedViewId?: string;
4
4
  uniprotId?: string;
5
5
  structureSequences?: string[];
6
+ /** the residue under the pointer, transient */
6
7
  hoverGenomeHighlights?: {
7
8
  start: number;
8
9
  end: number;
9
10
  }[];
11
+ /** the clicked domain, persistent; also what `initialSelection` lights */
12
+ clickGenomeHighlights?: {
13
+ start: number;
14
+ end: number;
15
+ }[];
10
16
  }
11
17
  export interface ProteinView {
12
18
  type: 'ProteinView';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import BgzipFastaMsaAdapterF from './BgzipFastaMsaAdapter';
8
8
  import LaunchMsaViewF from './LaunchMsaView';
9
9
  import LaunchMsaViewExtensionPointF from './LaunchMsaViewExtensionPoint';
10
10
  import MsaViewF from './MsaViewPanel';
11
+ import { launchMsaView } from './utils/launchMsaView';
11
12
  import { version } from './version';
12
13
  export default class MsaViewPlugin extends Plugin {
13
14
  constructor() {
@@ -49,7 +50,9 @@ export default class MsaViewPlugin extends Plugin {
49
50
  label: 'Multiple sequence alignment view',
50
51
  icon: GridOn,
51
52
  onClick: (session) => {
52
- session.addView('MsaView', {});
53
+ // stacked, by default: nothing was launched from, so there is no
54
+ // connected view for it to sit beside
55
+ launchMsaView(session, {});
53
56
  },
54
57
  });
55
58
  }