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
@@ -50,7 +50,7 @@ const BlastAutomaticPanel = observer(function ({ handleClose, feature, model, ch
50
50
  React.createElement(Typography, null, "Previous BLAST Results")),
51
51
  React.createElement(AccordionDetails, null,
52
52
  React.createElement(CachedBlastResults, { model: model, handleClose: handleClose, feature: feature })))) : null),
53
- React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence, onSubmit: () => {
53
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !proteinSequence, onSubmit: () => {
54
54
  try {
55
55
  if (selectedTranscript) {
56
56
  setLaunchViewError(undefined);
@@ -1,5 +1,16 @@
1
1
  import React from 'react';
2
2
  import type { AbstractTrackModel, Feature } from '@jbrowse/core/util';
3
+ /**
4
+ * The route to NCBI's `nr`, which no plugin version can query directly: NCBI
5
+ * stopped sending Access-Control-Allow-Origin to third-party origins, so the
6
+ * browser cannot read Blast.cgi at all (see docs/blast.md).
7
+ *
8
+ * That makes the round trip through NCBI's own site the whole feature rather
9
+ * than a fallback, so the panel walks it end to end. It used to hand the user a
10
+ * link, tell them to "paste the results into JBrowse", and offer only a Close
11
+ * button -- leaving them to find the Manual upload tab, re-pick the transcript
12
+ * they had already chosen here, and hand-type the row name.
13
+ */
3
14
  declare const BlastManualPanel: ({ handleClose, feature, model, children, }: {
4
15
  children: React.ReactNode;
5
16
  model: AbstractTrackModel;
@@ -1,45 +1,102 @@
1
- import React from 'react';
1
+ import React, { useState } from 'react';
2
2
  import { shorten2 } from '@jbrowse/core/util';
3
- import { Button, DialogActions, Typography } from '@mui/material';
3
+ import { Alert, Typography } from '@mui/material';
4
4
  import { observer } from 'mobx-react';
5
5
  import { makeStyles } from 'tss-react/mui';
6
6
  import { BASE_BLAST_URL } from './consts';
7
7
  import ExternalLink from '../../../components/ExternalLink';
8
- import { cleanProteinSequence, getLinearGenomeView } from '../../util';
8
+ import TextField2 from '../../../components/TextField2';
9
+ import { useQueryRowName } from '../../useQueryRowName';
10
+ import { cleanProteinSequence, getGeneDisplayName, getLinearGenomeView, } from '../../util';
9
11
  import LaunchPanelContent from '../LaunchPanelContent';
12
+ import { launchView } from '../ManualMSALoader/launchView';
13
+ import QueryRowSelector from '../QueryRowSelector';
14
+ import SubmitCancelActions from '../SubmitCancelActions';
10
15
  import TranscriptSelector from '../TranscriptSelector';
11
16
  import { useTranscriptSelection } from '../useTranscriptSelection';
12
17
  const useStyles = makeStyles()({
13
18
  ncbiLink: {
14
19
  wordBreak: 'break-all',
15
- margin: 30,
16
- maxWidth: 600,
17
20
  },
18
- infoText: {
21
+ textAreaFont: {
22
+ fontFamily: 'Courier New',
23
+ },
24
+ msaInput: {
25
+ marginBottom: 20,
26
+ },
27
+ step: {
19
28
  marginTop: 20,
20
29
  },
30
+ stepBody: {
31
+ marginLeft: 20,
32
+ marginTop: 8,
33
+ },
21
34
  });
35
+ /**
36
+ * The route to NCBI's `nr`, which no plugin version can query directly: NCBI
37
+ * stopped sending Access-Control-Allow-Origin to third-party origins, so the
38
+ * browser cannot read Blast.cgi at all (see docs/blast.md).
39
+ *
40
+ * That makes the round trip through NCBI's own site the whole feature rather
41
+ * than a fallback, so the panel walks it end to end. It used to hand the user a
42
+ * link, tell them to "paste the results into JBrowse", and offer only a Close
43
+ * button -- leaving them to find the Manual upload tab, re-pick the transcript
44
+ * they had already chosen here, and hand-type the row name.
45
+ */
22
46
  const BlastManualPanel = observer(function ({ handleClose, feature, model, children, }) {
23
47
  const { classes } = useStyles();
24
48
  const view = getLinearGenomeView(model);
49
+ const [launchViewError, setLaunchViewError] = useState();
50
+ const [msaText, setMsaText] = useState('');
51
+ const [treeText, setTreeText] = useState('');
25
52
  const transcriptSelection = useTranscriptSelection({ feature, view });
26
- const { proteinSequence, error } = transcriptSelection;
53
+ const { proteinSequence, selectedTranscript, error } = transcriptSelection;
54
+ const queryRow = useQueryRowName(msaText, proteinSequence);
27
55
  const s2 = cleanProteinSequence(proteinSequence);
28
56
  // a link the user follows to NCBI's own site, not something we fetch — which
29
57
  // is exactly why this route still works when the automatic one cannot
30
58
  const link = `${BASE_BLAST_URL}?PAGE_TYPE=BlastSearch&PAGE=Proteins&PROGRAM=blastp&QUERY=${s2}`;
31
59
  const link2 = `${BASE_BLAST_URL}?PAGE_TYPE=BlastSearch&PAGE=Proteins&PROGRAM=blastp&QUERY=${shorten2(s2, 10)}`;
32
60
  return (React.createElement(React.Fragment, null,
33
- React.createElement(LaunchPanelContent, { error: error },
61
+ React.createElement(LaunchPanelContent, { error: launchViewError ?? error },
34
62
  children,
35
63
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection }),
36
- proteinSequence ? (React.createElement("div", { className: classes.ncbiLink },
37
- "Link to NCBI BLAST: ",
38
- React.createElement(ExternalLink, { href: link }, link2))) : null,
39
- React.createElement(Typography, { className: classes.infoText }, "Click the link above and run your BLAST query, and once you have results, click \"Multiple Alignment\" at the top of the results page to be redirected to COBALT, NCBI's multiple sequence aligner. Once COBALT completes, you can download an MSA (.aln file) and optionally a Newick tree (.nh) and paste the results into JBrowse")),
40
- React.createElement(DialogActions, null,
41
- React.createElement(Button, { color: "primary", variant: "contained", onClick: () => {
42
- handleClose();
43
- } }, "Close"))));
64
+ React.createElement("div", { className: classes.step },
65
+ React.createElement(Typography, { variant: "subtitle2" }, "1. Run the search at NCBI"),
66
+ React.createElement("div", { className: classes.stepBody }, proteinSequence ? (React.createElement("div", { className: classes.ncbiLink },
67
+ React.createElement(ExternalLink, { href: link }, link2))) : (React.createElement(Alert, { severity: "info" }, "Pick a transcript above to get a link carrying its protein sequence.")))),
68
+ React.createElement("div", { className: classes.step },
69
+ React.createElement(Typography, { variant: "subtitle2" }, "2. Align the hits"),
70
+ React.createElement("div", { className: classes.stepBody },
71
+ React.createElement(Typography, null, "On the results page click \"Multiple Alignment\" to run COBALT, NCBI's aligner. Download the alignment (.aln) and, if you want the tree drawn, the Newick tree (.nh)."))),
72
+ React.createElement("div", { className: classes.step },
73
+ React.createElement(Typography, { variant: "subtitle2" }, "3. Paste the results back here"),
74
+ React.createElement("div", { className: classes.stepBody },
75
+ React.createElement(TextField2, { variant: "outlined", label: "Alignment", multiline: true, minRows: 5, maxRows: 10, fullWidth: true, className: classes.msaInput, slotProps: { input: { className: classes.textAreaFont } }, placeholder: "Paste the .aln contents here", value: msaText, onChange: event => {
76
+ setMsaText(event.target.value);
77
+ } }),
78
+ React.createElement(TextField2, { variant: "outlined", label: "Tree (optional)", multiline: true, minRows: 3, maxRows: 10, fullWidth: true, slotProps: { input: { className: classes.textAreaFont } }, placeholder: "Paste the .nh Newick tree here", value: treeText, onChange: event => {
79
+ setTreeText(event.target.value);
80
+ } }),
81
+ React.createElement(QueryRowSelector, { ...queryRow })))),
82
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !selectedTranscript || !msaText.trim(), onSubmit: () => {
83
+ try {
84
+ if (selectedTranscript) {
85
+ setLaunchViewError(undefined);
86
+ launchView({
87
+ newViewTitle: getGeneDisplayName(selectedTranscript),
88
+ view,
89
+ feature: selectedTranscript,
90
+ querySeqName: queryRow.querySeqName,
91
+ data: { msa: msaText, tree: treeText },
92
+ });
93
+ handleClose();
94
+ }
95
+ }
96
+ catch (e) {
97
+ console.error(e);
98
+ setLaunchViewError(e);
99
+ }
100
+ }, onCancel: handleClose })));
44
101
  });
45
102
  export default BlastManualPanel;
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function blastLaunchView({ newViewTitle, view, feature, blastParams, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  connectedViewId: view.id,
7
9
  connectedFeature: feature.toJSON(),
@@ -12,8 +14,8 @@ export function blastLaunchView({ newViewTitle, view, feature, blastParams, }) {
12
14
  });
13
15
  }
14
16
  export function blastLaunchViewFromCache({ newViewTitle, view, cached, connectedFeature, }) {
15
- getSession(view).addView('MsaView', {
16
- type: 'MsaView',
17
+ launchMsaView(getSession(view), {
18
+ placement: readLaunchPlacement(),
17
19
  displayName: newViewTitle,
18
20
  connectedViewId: view.id,
19
21
  connectedFeature,
@@ -7,6 +7,13 @@
7
7
  export declare const BASE_BLAST_URL = "https://blast.ncbi.nlm.nih.gov/Blast.cgi";
8
8
  export declare const msaAlgorithms: readonly ["clustalo", "muscle", "kalign", "mafft"];
9
9
  export type MsaAlgorithm = (typeof msaAlgorithms)[number];
10
- export declare const blastDatabaseOptions: readonly ["uniprotkb_swissprot", "uniprotkb", "uniprotkb_reference_proteomes", "uniprotkb_trembl"];
10
+ /**
11
+ * EBI rejects a submission naming a database outside its own list with a 400,
12
+ * so every value here has to appear in
13
+ * https://www.ebi.ac.uk/Tools/services/rest/ncbiblast/parameterdetails/database
14
+ * -- `uniprotkb_reference_proteomes` did not, and 3.0.0 shipped it as a dead
15
+ * menu entry.
16
+ */
17
+ export declare const blastDatabaseOptions: readonly ["uniprotkb_swissprot", "uniprotkb", "pan_proteomes", "uniprotkb_trembl"];
11
18
  export type BlastDatabase = (typeof blastDatabaseOptions)[number];
12
19
  export declare const defaultBlastDatabase: BlastDatabase;
@@ -6,10 +6,17 @@
6
6
  */
7
7
  export const BASE_BLAST_URL = 'https://blast.ncbi.nlm.nih.gov/Blast.cgi';
8
8
  export const msaAlgorithms = ['clustalo', 'muscle', 'kalign', 'mafft'];
9
+ /**
10
+ * EBI rejects a submission naming a database outside its own list with a 400,
11
+ * so every value here has to appear in
12
+ * https://www.ebi.ac.uk/Tools/services/rest/ncbiblast/parameterdetails/database
13
+ * -- `uniprotkb_reference_proteomes` did not, and 3.0.0 shipped it as a dead
14
+ * menu entry.
15
+ */
9
16
  export const blastDatabaseOptions = [
10
17
  'uniprotkb_swissprot',
11
18
  'uniprotkb',
12
- 'uniprotkb_reference_proteomes',
19
+ 'pan_proteomes',
13
20
  'uniprotkb_trembl',
14
21
  ];
15
22
  // curated, so it returns roughly one good sequence per species rather than the
@@ -1,12 +1,14 @@
1
1
  import React, { useState } from 'react';
2
2
  import { FileSelector } from '@jbrowse/core/ui';
3
- import { Alert, FormControl, FormControlLabel, Radio, RadioGroup, } from '@mui/material';
3
+ import { FormControl, FormControlLabel, Radio, RadioGroup } from '@mui/material';
4
4
  import { observer } from 'mobx-react';
5
5
  import { makeStyles } from 'tss-react/mui';
6
6
  import { launchView } from './launchView';
7
7
  import TextField2 from '../../../components/TextField2';
8
+ import { useQueryRowName } from '../../useQueryRowName';
8
9
  import { getGeneDisplayName, getLinearGenomeView } from '../../util';
9
10
  import LaunchPanelContent from '../LaunchPanelContent';
11
+ import QueryRowSelector from '../QueryRowSelector';
10
12
  import SubmitCancelActions from '../SubmitCancelActions';
11
13
  import TranscriptSelector from '../TranscriptSelector';
12
14
  import { useTranscriptSelection } from '../useTranscriptSelection';
@@ -23,12 +25,6 @@ const useStyles = makeStyles()({
23
25
  msaInput: {
24
26
  marginBottom: 20,
25
27
  },
26
- queryNameInput: {
27
- marginTop: 20,
28
- },
29
- warningAlert: {
30
- marginTop: 10,
31
- },
32
28
  });
33
29
  const ManualMSALoader = observer(function PreLoadedMSA2({ model, feature, handleClose, }) {
34
30
  const view = getLinearGenomeView(model);
@@ -39,9 +35,9 @@ const ManualMSALoader = observer(function PreLoadedMSA2({ model, feature, handle
39
35
  const [treeText, setTreeText] = useState('');
40
36
  const [msaFileLocation, setMsaFileLocation] = useState();
41
37
  const [treeFileLocation, setTreeFileLocation] = useState();
42
- const [querySeqName, setQuerySeqName] = useState('');
43
38
  const transcriptSelection = useTranscriptSelection({ feature, view });
44
- const { selectedTranscript, error } = transcriptSelection;
39
+ const { selectedTranscript, proteinSequence, error } = transcriptSelection;
40
+ const queryRow = useQueryRowName(msaText, proteinSequence);
45
41
  const e = launchViewError ?? error;
46
42
  return (React.createElement(React.Fragment, null,
47
43
  React.createElement(LaunchPanelContent, { error: e },
@@ -61,11 +57,8 @@ const ManualMSALoader = observer(function PreLoadedMSA2({ model, feature, handle
61
57
  setTreeText(event.target.value);
62
58
  } })))),
63
59
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection }),
64
- React.createElement(TextField2, { variant: "outlined", name: "MSA row name", fullWidth: true, required: true, className: classes.queryNameInput, placeholder: "Row name in MSA that corresponds to the selected transcript", helperText: "Required: Specify the name of the row in your MSA that should be aligned with the selected transcript", value: querySeqName, onChange: event => {
65
- setQuerySeqName(event.target.value);
66
- } }),
67
- !querySeqName.trim() ? (React.createElement(Alert, { severity: "warning", className: classes.warningAlert }, "Without specifying the MSA row name, clicking on the MSA will not navigate to the corresponding genome position, and hovering highlights will not work.")) : null),
68
- React.createElement(SubmitCancelActions, { submitDisabled: !selectedTranscript ||
60
+ React.createElement(QueryRowSelector, { ...queryRow })),
61
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !selectedTranscript ||
69
62
  (inputMethod === 'file' && !msaFileLocation) ||
70
63
  (inputMethod === 'text' && !msaText.trim()), onSubmit: () => {
71
64
  try {
@@ -75,7 +68,7 @@ const ManualMSALoader = observer(function PreLoadedMSA2({ model, feature, handle
75
68
  newViewTitle: getGeneDisplayName(selectedTranscript),
76
69
  view,
77
70
  feature: selectedTranscript,
78
- querySeqName: querySeqName.trim(),
71
+ querySeqName: queryRow.querySeqName,
79
72
  ...(inputMethod === 'file'
80
73
  ? {
81
74
  msaFilehandle: msaFileLocation,
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function launchView({ newViewTitle, view, feature, msaFilehandle, treeFilehandle, querySeqName, data, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  connectedViewId: view.id,
7
9
  connectedFeature: feature.toJSON(),
@@ -40,7 +40,7 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
40
40
  setMaxSpecies(event.target.value);
41
41
  }, error: !rowCountValid, helperText: "the closest N species NCBI has" })),
42
42
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection })),
43
- React.createElement(SubmitCancelActions, { submitDisabled: !proteinSequence || !rowCountValid, onSubmit: () => {
43
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !proteinSequence || !rowCountValid, onSubmit: () => {
44
44
  try {
45
45
  if (selectedTranscript) {
46
46
  setLaunchViewError(undefined);
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function orthologLaunchView({ newViewTitle, view, feature, orthologParams, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  connectedViewId: view.id,
7
9
  connectedFeature: feature.toJSON(),
@@ -58,7 +58,7 @@ const PreLoadedMSA = observer(function ({ model, feature, handleClose, }) {
58
58
  msaList ? (React.createElement("div", null,
59
59
  React.createElement(SanitizedHTML, { html: selectedDataset.description }),
60
60
  React.createElement(TranscriptSelector, { feature: feature, ...transcriptSelection }))) : null)) : null),
61
- React.createElement(SubmitCancelActions, { submitDisabled: !selectedTranscript || !msaData?.length, onSubmit: () => {
61
+ React.createElement(SubmitCancelActions, { model: model, submitDisabled: !selectedTranscript || !msaData?.length, onSubmit: () => {
62
62
  try {
63
63
  if (selectedTranscript && msaData) {
64
64
  const querySeqName = `${selectedId}_${assemblyNames[0]}`;
@@ -1,7 +1,9 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { launchMsaView } from '../../../utils/launchMsaView';
3
+ import { readLaunchPlacement } from '../../../utils/workspaces';
2
4
  export function preCalculatedLaunchView({ newViewTitle, view, feature, data, querySeqName, }) {
3
- getSession(view).addView('MsaView', {
4
- type: 'MsaView',
5
+ launchMsaView(getSession(view), {
6
+ placement: readLaunchPlacement(),
5
7
  displayName: newViewTitle,
6
8
  treeAreaWidth: 200,
7
9
  querySeqName,
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import type { QueryRowMatch } from '../detectQueryRow';
3
+ /**
4
+ * Which MSA row corresponds to the selected transcript. Clicking and hovering in
5
+ * the alignment reach the genome only through this name, and a wrong one fails
6
+ * silently -- the view opens, renders, and never navigates -- so the field fills
7
+ * itself in from the pasted alignment and offers that alignment's own row names
8
+ * rather than a free text box the user can typo.
9
+ */
10
+ export default function QueryRowSelector({ names, detected, querySeqName, setQuerySeqName, isAutoDetected, }: {
11
+ names: string[];
12
+ detected?: QueryRowMatch;
13
+ querySeqName: string;
14
+ setQuerySeqName: (arg: string) => void;
15
+ isAutoDetected: boolean;
16
+ }): React.JSX.Element;
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { Alert, MenuItem } from '@mui/material';
3
+ import { makeStyles } from 'tss-react/mui';
4
+ import TextField2 from '../../components/TextField2';
5
+ const useStyles = makeStyles()({
6
+ field: {
7
+ marginTop: 20,
8
+ },
9
+ alert: {
10
+ marginTop: 10,
11
+ },
12
+ });
13
+ /**
14
+ * Which MSA row corresponds to the selected transcript. Clicking and hovering in
15
+ * the alignment reach the genome only through this name, and a wrong one fails
16
+ * silently -- the view opens, renders, and never navigates -- so the field fills
17
+ * itself in from the pasted alignment and offers that alignment's own row names
18
+ * rather than a free text box the user can typo.
19
+ */
20
+ export default function QueryRowSelector({ names, detected, querySeqName, setQuerySeqName, isAutoDetected, }) {
21
+ const { classes } = useStyles();
22
+ return (React.createElement(React.Fragment, null,
23
+ names.length > 0 ? (React.createElement(TextField2, { variant: "outlined", label: "MSA row matching the selected transcript", select: true, fullWidth: true, className: classes.field, value: names.includes(querySeqName) ? querySeqName : '', onChange: event => {
24
+ setQuerySeqName(event.target.value);
25
+ } }, names.map(name => (React.createElement(MenuItem, { value: name, key: name },
26
+ name,
27
+ detected?.name === name ? ' — matches your protein' : ''))))) : (React.createElement(TextField2, { variant: "outlined", label: "MSA row matching the selected transcript", fullWidth: true, className: classes.field, helperText: "Paste an alignment above and this fills in on its own", value: querySeqName, onChange: event => {
28
+ setQuerySeqName(event.target.value);
29
+ } })),
30
+ isAutoDetected && detected ? (React.createElement(Alert, { severity: "success", className: classes.alert },
31
+ "Matched ",
32
+ React.createElement("strong", null, detected.name),
33
+ " to your protein sequence",
34
+ detected.quality === 'exact'
35
+ ? ''
36
+ : `, covering ${Math.round(detected.identity * 100)}% of it`,
37
+ ". Clicking the alignment will navigate the genome view.")) : names.length > 0 && !querySeqName ? (React.createElement(Alert, { severity: "warning", className: classes.alert }, "No row matched your protein sequence \u2014 pick the one for your gene above. Without it the alignment still renders, but clicking it will not navigate the genome view.")) : null));
38
+ }
@@ -1,8 +1,11 @@
1
1
  import React from 'react';
2
- export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel, cancelLabel, }: {
2
+ import type { AbstractTrackModel } from '@jbrowse/core/util';
3
+ export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel, cancelLabel, model, }: {
3
4
  onSubmit: () => void;
4
5
  onCancel: () => void;
5
6
  submitDisabled?: boolean;
6
7
  submitLabel?: string;
7
8
  cancelLabel?: string;
9
+ /** omitted by a panel that submits something other than a view launch */
10
+ model?: AbstractTrackModel;
8
11
  }): React.JSX.Element;
@@ -1,11 +1,35 @@
1
- import React from 'react';
2
- import { Button, DialogActions } from '@mui/material';
3
- export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel = 'Submit', cancelLabel = 'Cancel', }) {
4
- return (React.createElement(DialogActions, null,
5
- React.createElement(Button, { color: "primary", variant: "contained", disabled: submitDisabled, onClick: () => {
6
- onSubmit();
7
- } }, submitLabel),
8
- React.createElement(Button, { color: "secondary", variant: "contained", onClick: () => {
9
- onCancel();
10
- } }, cancelLabel)));
1
+ import React, { useState } from 'react';
2
+ import { getSession } from '@jbrowse/core/util';
3
+ import { Button, Checkbox, DialogActions, FormControlLabel, } from '@mui/material';
4
+ import { readLaunchPlacement, sessionSupportsPlacement, writeLaunchPlacement, } from '../../utils/workspaces';
5
+ /**
6
+ * Where the launch puts the view, offered wherever a launch is submitted.
7
+ *
8
+ * A checkbox rather than a menu of the three placements: the choice a reader
9
+ * has at this point is "beside the genome view or under it", and `newTab` is a
10
+ * spec's to state, not a thing to pick before you have seen the alignment.
11
+ *
12
+ * Absent entirely on a host that cannot tile — an embedded session, or a
13
+ * release that places views its own way — because the box would do nothing
14
+ * there and every launch would quietly ignore it.
15
+ */
16
+ function PlacementToggle({ model }) {
17
+ const session = getSession(model);
18
+ const [sideBySide, setSideBySide] = useState(() => readLaunchPlacement() === 'splitRight');
19
+ return sessionSupportsPlacement(session) ? (React.createElement(FormControlLabel, { label: "Open beside the genome view", control: React.createElement(Checkbox, { checked: sideBySide, onChange: event => {
20
+ const { checked } = event.target;
21
+ setSideBySide(checked);
22
+ writeLaunchPlacement(checked ? 'splitRight' : 'stack');
23
+ } }) })) : null;
24
+ }
25
+ export default function SubmitCancelActions({ onSubmit, onCancel, submitDisabled, submitLabel = 'Submit', cancelLabel = 'Cancel', model, }) {
26
+ return (React.createElement(DialogActions, { sx: { flexWrap: 'wrap', rowGap: 1 } },
27
+ model ? React.createElement(PlacementToggle, { model: model }) : null,
28
+ React.createElement("div", { style: { display: 'flex', gap: 8, marginLeft: 'auto' } },
29
+ React.createElement(Button, { sx: { flexShrink: 0 }, color: "primary", variant: "contained", disabled: submitDisabled, onClick: () => {
30
+ onSubmit();
31
+ } }, submitLabel),
32
+ React.createElement(Button, { sx: { flexShrink: 0 }, color: "secondary", variant: "contained", onClick: () => {
33
+ onCancel();
34
+ } }, cancelLabel))));
11
35
  }
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
5
+ import SubmitCancelActions from './SubmitCancelActions';
6
+ import { LAUNCH_PLACEMENT_KEY } from '../../utils/workspaces';
7
+ // getSession walks the MST tree, and this component only wants the two actions
8
+ // off the far end of that walk
9
+ vi.mock('@jbrowse/core/util', () => ({
10
+ getSession: (model) => model.session,
11
+ }));
12
+ function trackModel(session) {
13
+ return { session };
14
+ }
15
+ const tiling = { setUseWorkspaces() { }, setPendingMove() { } };
16
+ beforeEach(() => {
17
+ localStorage.clear();
18
+ });
19
+ afterEach(() => {
20
+ cleanup();
21
+ });
22
+ function toggle() {
23
+ return screen.queryByRole('checkbox');
24
+ }
25
+ test('a host that can tile offers the choice, checked by default', () => {
26
+ render(React.createElement(SubmitCancelActions, { model: trackModel(tiling), onSubmit: () => { }, onCancel: () => { } }));
27
+ expect(toggle()).toBeTruthy();
28
+ expect(toggle().checked).toBe(true);
29
+ expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBeNull();
30
+ });
31
+ // the box would do nothing on an embedded session, and a control that silently
32
+ // does nothing is worse than one that is not there
33
+ test('a host that cannot tile does not offer it', () => {
34
+ render(React.createElement(SubmitCancelActions, { model: trackModel({}), onSubmit: () => { }, onCancel: () => { } }));
35
+ expect(toggle()).toBeNull();
36
+ expect(screen.getByText('Submit')).toBeTruthy();
37
+ });
38
+ test('a panel that launches nothing passes no model, and gets no box', () => {
39
+ render(React.createElement(SubmitCancelActions, { onSubmit: () => { }, onCancel: () => { } }));
40
+ expect(toggle()).toBeNull();
41
+ });
42
+ test('clicking it writes the placement the next launch will read', () => {
43
+ render(React.createElement(SubmitCancelActions, { model: trackModel(tiling), onSubmit: () => { }, onCancel: () => { } }));
44
+ toggle().click();
45
+ expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBe('stack');
46
+ expect(toggle().checked).toBe(false);
47
+ toggle().click();
48
+ expect(localStorage.getItem(LAUNCH_PLACEMENT_KEY)).toBe('splitRight');
49
+ expect(toggle().checked).toBe(true);
50
+ });
51
+ test('a stored choice is what the box opens on', () => {
52
+ localStorage.setItem(LAUNCH_PLACEMENT_KEY, 'stack');
53
+ render(React.createElement(SubmitCancelActions, { model: trackModel(tiling), onSubmit: () => { }, onCancel: () => { } }));
54
+ expect(toggle().checked).toBe(false);
55
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Which row of a pasted alignment is the gene the user launched from.
3
+ *
4
+ * The MsaView needs that row name to tie alignment columns back to genome
5
+ * coordinates, and until now the user typed it. Nothing validates a typo: the
6
+ * view opens, renders, and simply never navigates or highlights, which reads as
7
+ * a broken feature rather than a wrong field. Meanwhile the plugin already
8
+ * knows the protein sequence it sent to BLAST, so it can find the row by
9
+ * sequence instead of asking.
10
+ *
11
+ * NCBI and EBI both rename the query on the way through -- COBALT emits
12
+ * `Query_1`, EBI's aligners carry the accession -- so the name is no help. The
13
+ * residues are, and they survive every rename.
14
+ */
15
+ export type MatchQuality = 'exact' | 'partial' | 'similar';
16
+ export interface QueryRowMatch {
17
+ name: string;
18
+ quality: MatchQuality;
19
+ /** identity over the compared region, 0-1 */
20
+ identity: number;
21
+ }
22
+ export declare function detectQueryRow(msaText: string, proteinSequence: string): QueryRowMatch | undefined;
23
+ export declare function getMsaRowNames(msaText: string): string[];
@@ -0,0 +1,94 @@
1
+ import { getUngappedSequence, parseMSA } from 'msa-parsers';
2
+ /**
3
+ * A stop codon is present in the transcript's translation and absent from
4
+ * anything an aligner returns, and case is not meaningful in either.
5
+ */
6
+ function normalize(seq) {
7
+ return seq
8
+ .replaceAll('*', '')
9
+ .replaceAll('-', '')
10
+ .replaceAll('.', '')
11
+ .toUpperCase();
12
+ }
13
+ function identityOverOverlap(a, b) {
14
+ const len = Math.min(a.length, b.length);
15
+ if (len === 0) {
16
+ return 0;
17
+ }
18
+ let same = 0;
19
+ for (let i = 0; i < len; i++) {
20
+ if (a[i] === b[i]) {
21
+ same++;
22
+ }
23
+ }
24
+ return same / len;
25
+ }
26
+ /**
27
+ * Below this, a "best" row is not a match at all -- an alignment of homologs is
28
+ * full of rows in the 40-70% range, and picking the top one would silently wire
29
+ * the view to a paralog from another species.
30
+ */
31
+ const SIMILARITY_FLOOR = 0.9;
32
+ /**
33
+ * How much of the query a contained row has to cover. A short fragment is a
34
+ * substring of almost any protein, so without a floor the first few residues of
35
+ * a half-pasted alignment match the query and the field fills in with a row the
36
+ * user is still typing.
37
+ */
38
+ const PARTIAL_COVERAGE_FLOOR = 0.5;
39
+ export function detectQueryRow(msaText, proteinSequence) {
40
+ const query = normalize(proteinSequence);
41
+ if (!query || !msaText.trim()) {
42
+ return undefined;
43
+ }
44
+ let names;
45
+ let parsed;
46
+ try {
47
+ const msa = parseMSA(msaText);
48
+ names = msa.getNames();
49
+ parsed = msa;
50
+ }
51
+ catch {
52
+ // a half-pasted alignment throws here on every keystroke; the caller shows
53
+ // the field rather than an error
54
+ return undefined;
55
+ }
56
+ const candidates = [];
57
+ for (const name of names) {
58
+ const row = normalize(getUngappedSequence(parsed.getRow(name)));
59
+ if (!row) {
60
+ continue;
61
+ }
62
+ if (row === query) {
63
+ // nothing beats an exact match, and a second one would be a duplicate row
64
+ return { name, quality: 'exact', identity: 1 };
65
+ }
66
+ // BLAST reports the aligned region, so the row is often the query trimmed
67
+ // at one or both ends rather than the whole protein
68
+ if (query.includes(row) || row.includes(query)) {
69
+ const coverage = Math.min(row.length, query.length) / Math.max(row.length, query.length);
70
+ if (coverage >= PARTIAL_COVERAGE_FLOOR) {
71
+ candidates.push({ name, quality: 'partial', identity: coverage });
72
+ }
73
+ continue;
74
+ }
75
+ const identity = identityOverOverlap(row, query);
76
+ if (identity >= SIMILARITY_FLOOR) {
77
+ candidates.push({ name, quality: 'similar', identity });
78
+ }
79
+ }
80
+ const order = ['exact', 'partial', 'similar'];
81
+ return candidates.sort((a, b) => order.indexOf(a.quality) - order.indexOf(b.quality) ||
82
+ b.identity - a.identity)[0];
83
+ }
84
+ export function getMsaRowNames(msaText) {
85
+ if (!msaText.trim()) {
86
+ return [];
87
+ }
88
+ try {
89
+ return parseMSA(msaText).getNames();
90
+ }
91
+ catch {
92
+ return [];
93
+ }
94
+ }
@@ -0,0 +1 @@
1
+ export {};