jbrowse-plugin-msaview 3.0.0 → 3.1.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.
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.d.ts +11 -0
- package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +73 -16
- package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +8 -1
- package/dist/LaunchMsaView/components/BlastQuery/consts.js +8 -1
- package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +7 -14
- package/dist/LaunchMsaView/components/QueryRowSelector.d.ts +16 -0
- package/dist/LaunchMsaView/components/QueryRowSelector.js +38 -0
- package/dist/LaunchMsaView/detectQueryRow.d.ts +23 -0
- package/dist/LaunchMsaView/detectQueryRow.js +94 -0
- package/dist/LaunchMsaView/detectQueryRow.test.d.ts +1 -0
- package/dist/LaunchMsaView/detectQueryRow.test.js +65 -0
- package/dist/LaunchMsaView/useQueryRowName.d.ts +15 -0
- package/dist/LaunchMsaView/useQueryRowName.js +26 -0
- package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +27 -11
- package/dist/MsaViewPanel/afterCreateAutoruns.js +96 -47
- package/dist/MsaViewPanel/observeProteinHighlights.test.d.ts +1 -0
- package/dist/MsaViewPanel/observeProteinHighlights.test.js +209 -0
- package/dist/MsaViewPanel/structureConnection.d.ts +6 -0
- package/dist/jbrowse-plugin-msaview.umd.production.min.js +28 -28
- package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
- package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +126 -30
- package/src/LaunchMsaView/components/BlastQuery/consts.ts +8 -1
- package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +7 -37
- package/src/LaunchMsaView/components/QueryRowSelector.tsx +93 -0
- package/src/LaunchMsaView/detectQueryRow.test.ts +79 -0
- package/src/LaunchMsaView/detectQueryRow.ts +132 -0
- package/src/LaunchMsaView/useQueryRowName.ts +33 -0
- package/src/MsaViewPanel/afterCreateAutoruns.ts +106 -51
- package/src/MsaViewPanel/observeProteinHighlights.test.ts +264 -0
- package/src/MsaViewPanel/structureConnection.ts +7 -0
- package/src/version.ts +1 -1
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
"
|
|
38
|
-
React.createElement(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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, { 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;
|
|
@@ -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
|
-
|
|
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
|
-
'
|
|
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 {
|
|
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,10 +57,7 @@ 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(
|
|
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),
|
|
60
|
+
React.createElement(QueryRowSelector, { ...queryRow })),
|
|
68
61
|
React.createElement(SubmitCancelActions, { submitDisabled: !selectedTranscript ||
|
|
69
62
|
(inputMethod === 'file' && !msaFileLocation) ||
|
|
70
63
|
(inputMethod === 'text' && !msaText.trim()), onSubmit: () => {
|
|
@@ -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
|
|
71
|
+
querySeqName: queryRow.querySeqName,
|
|
79
72
|
...(inputMethod === 'file'
|
|
80
73
|
? {
|
|
81
74
|
msaFilehandle: msaFileLocation,
|
|
@@ -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
|
+
}
|
|
@@ -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 {};
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
31
|
-
*
|
|
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
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* the
|
|
41
|
-
*
|
|
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;
|