jbrowse-plugin-protein3d 0.14.0 → 0.15.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/LaunchProteinView/components/FoldseekSearch.js +7 -3
- package/dist/LaunchProteinView/services/foldseekApi.js +17 -6
- package/dist/ProteinView/autoScroll.js +24 -23
- package/dist/ProteinView/components/FeatureBar.js +1 -6
- package/dist/ProteinView/components/FeatureTypeLabel.js +2 -5
- package/dist/ProteinView/components/ProteinAlignment.js +14 -33
- package/dist/ProteinView/components/ProteinFeatureTrack.js +2 -2
- package/dist/ProteinView/components/ProteinViewHeader.js +1 -1
- package/dist/ProteinView/components/SplitString.js +3 -21
- package/dist/ProteinView/entityAlignedTo.js +37 -0
- package/dist/ProteinView/frameSelection.js +34 -22
- package/dist/ProteinView/model.js +9 -2
- package/dist/ProteinView/proteinToGenomeMapping.js +18 -18
- package/dist/ProteinView/residueRanges.js +69 -0
- package/dist/ProteinView/structureModel.js +229 -81
- package/dist/ProteinView/subscribeMolstarInteraction.js +5 -8
- package/dist/ProteinView/viewInteractions.js +9 -7
- package/dist/jbrowse-plugin-protein3d.umd.production.min.js +15 -15
- package/dist/jbrowse-plugin-protein3d.umd.production.min.js.map +4 -4
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/LaunchProteinView/components/FoldseekSearch.tsx +11 -2
- package/src/LaunchProteinView/services/foldseekApi.test.ts +28 -0
- package/src/LaunchProteinView/services/foldseekApi.ts +23 -10
- package/src/LaunchProteinViewExtensionPoint/index.ts +10 -9
- package/src/ProteinView/autoScroll.test.ts +68 -13
- package/src/ProteinView/autoScroll.ts +47 -23
- package/src/ProteinView/components/FeatureBar.tsx +1 -6
- package/src/ProteinView/components/FeatureTypeLabel.tsx +1 -8
- package/src/ProteinView/components/ProteinAlignment.tsx +14 -43
- package/src/ProteinView/components/ProteinFeatureTrack.tsx +0 -3
- package/src/ProteinView/components/ProteinViewHeader.tsx +1 -0
- package/src/ProteinView/components/SplitString.tsx +12 -27
- package/src/ProteinView/entityAlignedTo.test.ts +56 -0
- package/src/ProteinView/entityAlignedTo.ts +60 -0
- package/src/ProteinView/frameSelection.test.ts +20 -6
- package/src/ProteinView/frameSelection.ts +67 -35
- package/src/ProteinView/geneExplorerLinkage.test.ts +16 -6
- package/src/ProteinView/model.test.ts +9 -7
- package/src/ProteinView/model.ts +9 -2
- package/src/ProteinView/proteinToGenomeMapping.ts +22 -21
- package/src/ProteinView/proteinViewSpec.ts +6 -4
- package/src/ProteinView/residueRanges.test.ts +100 -0
- package/src/ProteinView/residueRanges.ts +98 -0
- package/src/ProteinView/structureModel.test.ts +321 -13
- package/src/ProteinView/structureModel.ts +274 -107
- package/src/ProteinView/subscribeMolstarInteraction.test.ts +32 -0
- package/src/ProteinView/subscribeMolstarInteraction.ts +24 -2
- package/src/ProteinView/viewInteractions.test.ts +30 -13
- package/src/ProteinView/viewInteractions.ts +8 -7
- package/src/version.ts +1 -1
- package/src/ProteinView/components/matchRuns.test.ts +0 -26
|
@@ -10,7 +10,7 @@ import PartialFailureNotice from './PartialFailureNotice';
|
|
|
10
10
|
import TranscriptSelector from './TranscriptSelector';
|
|
11
11
|
import useFoldseekSearch from '../hooks/useFoldseekSearch';
|
|
12
12
|
import useTranscriptIsoformSelection from '../hooks/useTranscriptIsoformSelection';
|
|
13
|
-
import { DEFAULT_DATABASES } from '../services/foldseekApi';
|
|
13
|
+
import { DEFAULT_DATABASES, foldseekLengthProblem, } from '../services/foldseekApi';
|
|
14
14
|
const useStyles = makeStyles()({
|
|
15
15
|
dialogContent: {
|
|
16
16
|
width: '80em',
|
|
@@ -57,7 +57,11 @@ const FoldseekSearch = observer(function FoldseekSearch({ feature, preferredTran
|
|
|
57
57
|
invalidatePrediction();
|
|
58
58
|
};
|
|
59
59
|
const isBusy = isLoading || isPredicting;
|
|
60
|
-
const
|
|
60
|
+
const lengthProblem = foldseekLengthProblem(sequence);
|
|
61
|
+
const canSearch = sequence.trim().length > 0 &&
|
|
62
|
+
!lengthProblem &&
|
|
63
|
+
selectedDatabases.length > 0 &&
|
|
64
|
+
!isBusy;
|
|
61
65
|
// One button: predicting the 3Di alphabet is a step of the search, not a
|
|
62
66
|
// decision, and making the user click twice only invited a stale prediction.
|
|
63
67
|
const runSearch = async () => {
|
|
@@ -79,7 +83,7 @@ const FoldseekSearch = observer(function FoldseekSearch({ feature, preferredTran
|
|
|
79
83
|
React.createElement(TextField, { label: "Protein sequence (amino acids)", multiline: true, rows: 4, value: sequence, onChange: e => {
|
|
80
84
|
setUserEditedSequence(e.target.value);
|
|
81
85
|
invalidatePrediction();
|
|
82
|
-
}, placeholder: `MKTVRQERLKSIVRILERSKEPVSGAQLAEEL...`, disabled: isBusy, slotProps: {
|
|
86
|
+
}, placeholder: `MKTVRQERLKSIVRILERSKEPVSGAQLAEEL...`, disabled: isBusy, error: !!lengthProblem, helperText: lengthProblem, slotProps: {
|
|
83
87
|
input: { className: classes.sequenceInput },
|
|
84
88
|
} }))) : null,
|
|
85
89
|
di3Sequence ? (React.createElement("div", { className: classes.di3Section },
|
|
@@ -13,16 +13,27 @@ export const DEFAULT_DATABASES = [
|
|
|
13
13
|
'pdb100',
|
|
14
14
|
'afdb-swissprot',
|
|
15
15
|
];
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export const FOLDSEEK_MAX_RESIDUES = 1200;
|
|
17
|
+
export function cleanFoldseekSequence(aaSequence) {
|
|
18
|
+
return aaSequence
|
|
19
19
|
.split('\n')
|
|
20
20
|
.filter(line => !line.startsWith('>'))
|
|
21
21
|
.join('')
|
|
22
|
-
.replace(/\s/g, '')
|
|
23
|
-
.replace(/\*/g, '') // Remove stop codons before querying 3Di
|
|
24
22
|
.toUpperCase()
|
|
25
|
-
.replace(/[^ACDEFGHIKLMNPQRSTVWY]/g, '');
|
|
23
|
+
.replace(/[^ACDEFGHIKLMNPQRSTVWY]/g, '');
|
|
24
|
+
}
|
|
25
|
+
export function foldseekLengthProblem(aaSequence) {
|
|
26
|
+
const { length } = cleanFoldseekSequence(aaSequence);
|
|
27
|
+
return length > FOLDSEEK_MAX_RESIDUES
|
|
28
|
+
? `Foldseek's 3Di predictor takes at most ${FOLDSEEK_MAX_RESIDUES.toLocaleString('en-US')} residues and this sequence has ${length.toLocaleString('en-US')}. Trim it to the region you want to search.`
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
export async function predict3Di({ aaSequence, signal, }) {
|
|
32
|
+
const problem = foldseekLengthProblem(aaSequence);
|
|
33
|
+
if (problem) {
|
|
34
|
+
throw new Error(problem);
|
|
35
|
+
}
|
|
36
|
+
const cleanSequence = cleanFoldseekSequence(aaSequence);
|
|
26
37
|
const url = `https://3di.foldseek.com/predict/${encodeURIComponent(cleanSequence)}`;
|
|
27
38
|
const response = await rawfetch(url, { signal });
|
|
28
39
|
if (!response.ok) {
|
|
@@ -1,29 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Target scrollLeft for following a hovered column, but only on a *large jump* —
|
|
8
|
-
* a column that lands more than a full viewport outside the view (e.g. hovering
|
|
9
|
-
* a distant residue in the 3D structure). A column that has merely edged just
|
|
10
|
-
* off-screen during a continuous hover sweep returns undefined, so the panel
|
|
11
|
-
* doesn't feel like it's constantly re-centering. Centers the column when it
|
|
12
|
-
* does jump.
|
|
13
|
-
*/
|
|
14
|
-
export function largeJumpScrollTarget({ x, scrollLeft, clientWidth, }) {
|
|
15
|
-
const viewEnd = scrollLeft + clientWidth;
|
|
16
|
-
const gap = Math.max(scrollLeft - x, x - viewEnd);
|
|
17
|
-
return gap > clientWidth ? x - clientWidth / 2 : undefined;
|
|
1
|
+
import { reaction } from 'mobx';
|
|
2
|
+
import { CHAR_WIDTH } from './constants';
|
|
3
|
+
export function followHoverTarget({ x, width, scrollLeft, clientWidth, }) {
|
|
4
|
+
const visible = x >= scrollLeft && x + width <= scrollLeft + clientWidth;
|
|
5
|
+
return visible ? undefined : x + width / 2 - clientWidth / 2;
|
|
18
6
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Target scrollLeft to bring a selected [start, end) pixel range into view,
|
|
21
|
-
* centering it, but only when it currently lies entirely off-screen. Returns
|
|
22
|
-
* undefined when any part of the range is already visible, so a selection the
|
|
23
|
-
* user can already see is left where it is.
|
|
24
|
-
*/
|
|
25
7
|
export function offScreenCenterTarget({ start, end, scrollLeft, clientWidth, }) {
|
|
26
8
|
const viewEnd = scrollLeft + clientWidth;
|
|
27
9
|
const visible = end >= scrollLeft && start <= viewEnd;
|
|
28
10
|
return visible ? undefined : (start + end) / 2 - clientWidth / 2;
|
|
29
11
|
}
|
|
12
|
+
export function followHover(model, getContainer) {
|
|
13
|
+
return reaction(() => model.alignmentHoverPos, pos => {
|
|
14
|
+
const container = getContainer();
|
|
15
|
+
if (pos !== undefined &&
|
|
16
|
+
container &&
|
|
17
|
+
model.autoScrollAlignment &&
|
|
18
|
+
!model.isMouseInAlignment) {
|
|
19
|
+
const target = followHoverTarget({
|
|
20
|
+
x: pos * CHAR_WIDTH,
|
|
21
|
+
width: CHAR_WIDTH,
|
|
22
|
+
scrollLeft: container.scrollLeft,
|
|
23
|
+
clientWidth: container.clientWidth,
|
|
24
|
+
});
|
|
25
|
+
if (target !== undefined) {
|
|
26
|
+
container.scrollLeft = target;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -42,15 +42,10 @@ const FeatureBar = observer(function FeatureBar({ layout, top, model, }) {
|
|
|
42
42
|
setIsHovered(false);
|
|
43
43
|
model.setAlignmentHoverRange(undefined);
|
|
44
44
|
};
|
|
45
|
-
// The model's `select` autorun owns the magenta molstar selection, deriving
|
|
46
|
-
// it from clickedStructureRange. Setting/clearing that range here (rather than
|
|
47
|
-
// also driving molstar imperatively) keeps a single source of truth: on
|
|
48
|
-
// deselect the autorun correctly falls back to the whole-alignment highlight
|
|
49
|
-
// when showHighlight is on, instead of blanking the selection.
|
|
50
45
|
const handleClick = () => {
|
|
51
46
|
if (isSelected) {
|
|
52
47
|
model.setSelectedFeatureId(undefined);
|
|
53
|
-
model.
|
|
48
|
+
model.setClickedStructureRanges([]);
|
|
54
49
|
}
|
|
55
50
|
else {
|
|
56
51
|
model.setSelectedFeatureId(feature.uniqueId);
|
|
@@ -5,19 +5,16 @@ import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
|
|
5
5
|
import { IconButton, Tooltip } from '@mui/material';
|
|
6
6
|
import { observer } from 'mobx-react';
|
|
7
7
|
import { HIDE_BUTTON_COLOR } from '../constants';
|
|
8
|
-
const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, laneCount, expanded,
|
|
8
|
+
const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, laneCount, expanded, model, }) {
|
|
9
9
|
const lanes = expanded ? laneCount : 1;
|
|
10
10
|
const canExpand = laneCount > 1;
|
|
11
11
|
return (React.createElement(Tooltip, { title: type, placement: "left" },
|
|
12
12
|
React.createElement("div", { style: {
|
|
13
13
|
height: lanes * (model.trackHeight + model.trackGap),
|
|
14
|
-
width: labelWidth - 4,
|
|
15
14
|
fontSize: 9,
|
|
16
15
|
fontFamily: 'monospace',
|
|
17
16
|
textAlign: 'right',
|
|
18
17
|
paddingRight: 4,
|
|
19
|
-
overflow: 'hidden',
|
|
20
|
-
textOverflow: 'ellipsis',
|
|
21
18
|
whiteSpace: 'nowrap',
|
|
22
19
|
display: 'flex',
|
|
23
20
|
alignItems: expanded ? 'flex-start' : 'center',
|
|
@@ -35,6 +32,6 @@ const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, laneCount, e
|
|
|
35
32
|
}, title: expanded
|
|
36
33
|
? `Collapse ${type} track`
|
|
37
34
|
: `Expand ${type} track (${laneCount} overlapping rows)`, sx: { p: 0, color: HIDE_BUTTON_COLOR } }, expanded ? (React.createElement(UnfoldLessIcon, { sx: { fontSize: model.trackHeight } })) : (React.createElement(UnfoldMoreIcon, { sx: { fontSize: model.trackHeight } })))) : null,
|
|
38
|
-
React.createElement("span",
|
|
35
|
+
React.createElement("span", null, type))));
|
|
39
36
|
});
|
|
40
37
|
export default FeatureTypeLabel;
|
|
@@ -13,7 +13,7 @@ import { ProteinFeatureTrackContent, ProteinFeatureTrackLabels, } from './Protei
|
|
|
13
13
|
import ResidueValueTrack from './ResidueValueTrack';
|
|
14
14
|
import SplitString, { AlignmentHighlights } from './SplitString';
|
|
15
15
|
import ExternalLink from '../../components/ExternalLink';
|
|
16
|
-
import {
|
|
16
|
+
import { followHover, offScreenCenterTarget } from '../autoScroll';
|
|
17
17
|
import { CHAR_WIDTH, LABEL_WIDTH, ROW_HEIGHT } from '../constants';
|
|
18
18
|
import useProteinFeatureTrackData from '../hooks/useProteinFeatureTrackData';
|
|
19
19
|
import useStructureUniProt from '../hooks/useStructureUniProt';
|
|
@@ -93,37 +93,19 @@ const ProteinAlignment = observer(function ProteinAlignment({ model, }) {
|
|
|
93
93
|
? `Could not map ${label} to a UniProt entry through SIFTS`
|
|
94
94
|
: `Could not load UniProt features for ${uniprotId ?? label}`}: ${errorMessage(featureError)}`
|
|
95
95
|
: undefined;
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
96
|
+
useEffect(() => followHover(model, () => containerRef.current), [model]);
|
|
97
|
+
// Scroll a selection into view when it changes to an off-screen one — both
|
|
98
|
+
// a declared seed on open and a later click on a distant feature bar, which
|
|
99
|
+
// would otherwise select something the user can't see. Several ranges scroll
|
|
100
|
+
// to their first. Keyed on the ranges so it fires once per distinct
|
|
101
|
+
// selection and doesn't fight the user's own scrolling afterward.
|
|
100
102
|
useEffect(() => autorun(() => {
|
|
101
103
|
const container = containerRef.current;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
model.alignmentHoverPos !== undefined &&
|
|
105
|
-
container) {
|
|
106
|
-
const target = largeJumpScrollTarget({
|
|
107
|
-
x: model.alignmentHoverPos * CHAR_WIDTH,
|
|
108
|
-
scrollLeft: container.scrollLeft,
|
|
109
|
-
clientWidth: container.clientWidth,
|
|
110
|
-
});
|
|
111
|
-
if (target !== undefined) {
|
|
112
|
-
container.scrollTo({ left: target, behavior: 'smooth' });
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}), [model]);
|
|
116
|
-
// Scroll a selection into view when it changes to an off-screen range — both
|
|
117
|
-
// the declarative `initialSelection` on open and a later click on a distant
|
|
118
|
-
// feature bar, which would otherwise select something the user can't see.
|
|
119
|
-
// Keyed on the range so it fires once per distinct selection and doesn't fight
|
|
120
|
-
// the user's own scrolling afterward.
|
|
121
|
-
useEffect(() => autorun(() => {
|
|
122
|
-
const container = containerRef.current;
|
|
123
|
-
const range = model.clickAlignmentRange;
|
|
104
|
+
const ranges = model.clickAlignmentRanges;
|
|
105
|
+
const range = ranges[0];
|
|
124
106
|
if (container) {
|
|
125
107
|
if (range) {
|
|
126
|
-
const key = `${
|
|
108
|
+
const key = ranges.map(r => `${r.start}-${r.end}`).join(',');
|
|
127
109
|
if (key !== lastScrolledSelectionRef.current) {
|
|
128
110
|
lastScrolledSelectionRef.current = key;
|
|
129
111
|
const target = offScreenCenterTarget({
|
|
@@ -133,7 +115,7 @@ const ProteinAlignment = observer(function ProteinAlignment({ model, }) {
|
|
|
133
115
|
clientWidth: container.clientWidth,
|
|
134
116
|
});
|
|
135
117
|
if (target !== undefined) {
|
|
136
|
-
container.
|
|
118
|
+
container.scrollLeft = target;
|
|
137
119
|
}
|
|
138
120
|
}
|
|
139
121
|
}
|
|
@@ -173,12 +155,11 @@ const ProteinAlignment = observer(function ProteinAlignment({ model, }) {
|
|
|
173
155
|
}, onMouseEnter: () => {
|
|
174
156
|
model.setIsMouseInAlignment(true);
|
|
175
157
|
}, onMouseLeave: () => {
|
|
176
|
-
model.
|
|
177
|
-
model.setHoveredPosition(undefined);
|
|
158
|
+
model.leaveAlignment();
|
|
178
159
|
} },
|
|
179
160
|
React.createElement("div", { style: {
|
|
180
161
|
flexShrink: 0,
|
|
181
|
-
|
|
162
|
+
minWidth: LABEL_WIDTH,
|
|
182
163
|
textAlign: 'right',
|
|
183
164
|
paddingRight: 4,
|
|
184
165
|
} },
|
|
@@ -191,7 +172,7 @@ const ProteinAlignment = observer(function ProteinAlignment({ model, }) {
|
|
|
191
172
|
React.createElement("span", null, "STRUCT"))),
|
|
192
173
|
React.createElement(GutterLabel, { label: "residue", title: "Residue numbers as the structure's authors assigned them, the numbering papers and the 3D view's hover label use", height: ROW_HEIGHT }),
|
|
193
174
|
showProteinTracks ? (featureLoading ? (React.createElement("div", { className: classes.gutterStatus }, "Loading...")) : featureErrorMessage ? (React.createElement(Tooltip, { title: featureErrorMessage },
|
|
194
|
-
React.createElement("div", { className: classes.gutterError }, "Error"))) : featureData ? (React.createElement(ProteinFeatureTrackLabels, { data: featureData,
|
|
175
|
+
React.createElement("div", { className: classes.gutterError }, "Error"))) : featureData ? (React.createElement(ProteinFeatureTrackLabels, { data: featureData, model: model })) : null) : null,
|
|
195
176
|
showProteinTracks && confidenceCells.length > 0 ? (React.createElement(GutterLabel, { label: "pLDDT", title: "AlphaFold per-residue confidence (pLDDT)", height: model.trackHeight + model.trackGap })) : null,
|
|
196
177
|
showProteinTracks && hydrophobicityCells.length > 0 ? (React.createElement(GutterLabel, { label: "hydro", title: "Kyte-Doolittle hydrophobicity (orange hydrophobic, blue hydrophilic)", height: model.trackHeight + model.trackGap })) : null),
|
|
197
178
|
React.createElement("div", { ref: containerRef, className: classes.scroll },
|
|
@@ -14,8 +14,8 @@ const FeatureTypeTrackContent = observer(function FeatureTypeTrackContent({ grou
|
|
|
14
14
|
marginBottom: model.trackGap,
|
|
15
15
|
} }, group.layouts.map(layout => (React.createElement(FeatureBar, { key: layout.feature.uniqueId, layout: layout, top: (expanded ? layout.lane : 0) * laneUnit, model: model })))));
|
|
16
16
|
});
|
|
17
|
-
export const ProteinFeatureTrackLabels = observer(function ProteinFeatureTrackLabels({ data,
|
|
18
|
-
return (React.createElement(React.Fragment, null, data.visibleGroups.map(group => (React.createElement(FeatureTypeLabel, { key: group.type, type: group.type, laneCount: group.laneCount, expanded: model.expandedFeatureTypes.has(group.type),
|
|
17
|
+
export const ProteinFeatureTrackLabels = observer(function ProteinFeatureTrackLabels({ data, model, }) {
|
|
18
|
+
return (React.createElement(React.Fragment, null, data.visibleGroups.map(group => (React.createElement(FeatureTypeLabel, { key: group.type, type: group.type, laneCount: group.laneCount, expanded: model.expandedFeatureTypes.has(group.type), model: model })))));
|
|
19
19
|
});
|
|
20
20
|
export const ProteinFeatureTrackContent = observer(function ProteinFeatureTrackContent({ data, model, }) {
|
|
21
21
|
const hoverHandlers = useAlignmentColumnHover(model, data.sequenceLength);
|
|
@@ -43,7 +43,7 @@ const DisplaySettingsMenu = observer(function DisplaySettingsMenu({ model, }) {
|
|
|
43
43
|
setAnchorEl(event.currentTarget);
|
|
44
44
|
} },
|
|
45
45
|
React.createElement(TuneIcon, { fontSize: "small" }))),
|
|
46
|
-
React.createElement(Menu, { anchorEl: anchorEl, open: Boolean(anchorEl), onClose: () => {
|
|
46
|
+
React.createElement(Menu, { keepMounted: true, anchorEl: anchorEl, open: Boolean(anchorEl), onClose: () => {
|
|
47
47
|
setAnchorEl(null);
|
|
48
48
|
} },
|
|
49
49
|
model.displayToggles.map(toggle => (React.createElement(ToggleMenuItem, { key: toggle.label, checked: toggle.checked, label: toggle.label, onToggle: toggle.toggle }))),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { observer } from 'mobx-react';
|
|
3
3
|
import { CHAR_WIDTH } from '../constants';
|
|
4
|
+
import { positionRuns } from '../residueRanges';
|
|
4
5
|
const CharacterSpans = observer(function CharacterSpans({ str, }) {
|
|
5
6
|
return str.split('').map((char, i) => (React.createElement("span", { key: i, style: {
|
|
6
7
|
position: 'absolute',
|
|
@@ -8,30 +9,11 @@ const CharacterSpans = observer(function CharacterSpans({ str, }) {
|
|
|
8
9
|
width: CHAR_WIDTH,
|
|
9
10
|
} }, char === ' ' ? '\u00A0' : char)));
|
|
10
11
|
});
|
|
11
|
-
/**
|
|
12
|
-
* Collapse a set of matching columns into contiguous [start, end) runs. A
|
|
13
|
-
* well-matched alignment is nearly all one run, so this turns one DOM node per
|
|
14
|
-
* residue into a handful for the whole overlay.
|
|
15
|
-
*/
|
|
16
|
-
export function matchRuns(columns) {
|
|
17
|
-
const sorted = [...columns].sort((a, b) => a - b);
|
|
18
|
-
const runs = [];
|
|
19
|
-
for (const col of sorted) {
|
|
20
|
-
const last = runs.at(-1);
|
|
21
|
-
if (last?.end === col) {
|
|
22
|
-
last.end = col + 1;
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
runs.push({ start: col, end: col + 1 });
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return runs;
|
|
29
|
-
}
|
|
30
12
|
const MatchOverlays = observer(function MatchOverlays({ model, height, }) {
|
|
31
13
|
const { showHighlight, alignmentMatchSet } = model;
|
|
32
14
|
return !showHighlight || !alignmentMatchSet
|
|
33
15
|
? null
|
|
34
|
-
:
|
|
16
|
+
: positionRuns(alignmentMatchSet).map(run => (React.createElement("span", { key: run.start, style: {
|
|
35
17
|
position: 'absolute',
|
|
36
18
|
left: run.start * CHAR_WIDTH,
|
|
37
19
|
top: 0,
|
|
@@ -89,7 +71,7 @@ export const AlignmentHighlights = observer(function AlignmentHighlights({ model
|
|
|
89
71
|
pointerEvents: 'none',
|
|
90
72
|
} },
|
|
91
73
|
React.createElement(MatchOverlays, { model: model, height: height }),
|
|
92
|
-
React.createElement(RangeHighlight, {
|
|
74
|
+
model.clickAlignmentRanges.map(range => (React.createElement(RangeHighlight, { key: range.start, range: range, strLength: strLength, background: "rgba(0, 120, 255, 0.3)", border: "1px solid rgba(0, 120, 255, 0.6)", height: height }))),
|
|
93
75
|
React.createElement(RangeHighlight, { range: model.alignmentHoverRange, strLength: strLength, background: "rgba(255, 165, 0, 0.4)", height: height }),
|
|
94
76
|
React.createElement(HoverHighlight, { model: model, strLength: strLength, height: height })));
|
|
95
77
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { pairwiseAlignmentProblem, pairwiseAlignmentSequenceProblem, stripStopCodon, structureAlignedSeq, transcriptAlignedSeq, } from 'p2s_mapper';
|
|
2
|
+
function withoutStopColumn(alignment) {
|
|
3
|
+
const t = transcriptAlignedSeq(alignment);
|
|
4
|
+
const s = structureAlignedSeq(alignment);
|
|
5
|
+
const [a, b] = alignment.alns;
|
|
6
|
+
return t.endsWith('*') && s.endsWith('-')
|
|
7
|
+
? {
|
|
8
|
+
consensus: alignment.consensus.slice(0, -1),
|
|
9
|
+
alns: [
|
|
10
|
+
{ ...a, seq: t.slice(0, -1) },
|
|
11
|
+
{ ...b, seq: s.slice(0, -1) },
|
|
12
|
+
],
|
|
13
|
+
}
|
|
14
|
+
: alignment;
|
|
15
|
+
}
|
|
16
|
+
export function entityAlignedTo(supplied, transcript, entities, preferredId) {
|
|
17
|
+
const alignment = withoutStopColumn(supplied);
|
|
18
|
+
const t = stripStopCodon(transcript);
|
|
19
|
+
const shape = pairwiseAlignmentProblem(alignment);
|
|
20
|
+
if (shape) {
|
|
21
|
+
return { problem: shape };
|
|
22
|
+
}
|
|
23
|
+
const ownRow = structureAlignedSeq(alignment).replaceAll('-', '');
|
|
24
|
+
const transcriptProblem = pairwiseAlignmentSequenceProblem(alignment, t, ownRow);
|
|
25
|
+
if (transcriptProblem) {
|
|
26
|
+
return { problem: transcriptProblem };
|
|
27
|
+
}
|
|
28
|
+
const rank = (e) => (e.entityId === preferredId ? 0 : 2) + (e.nucleicAcid ? 1 : 0);
|
|
29
|
+
const match = [...entities]
|
|
30
|
+
.sort((a, b) => rank(a) - rank(b))
|
|
31
|
+
.find(e => !pairwiseAlignmentSequenceProblem(alignment, t, stripStopCodon(e.seq)));
|
|
32
|
+
return match
|
|
33
|
+
? { entityId: match.entityId }
|
|
34
|
+
: {
|
|
35
|
+
problem: `The second sequence (${ownRow.length} residues) is not the sequence of any chain in this structure`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,34 +1,56 @@
|
|
|
1
1
|
import { residueLoci } from './applyLociInteractivity';
|
|
2
2
|
import loadMolstar from './loadMolstar';
|
|
3
|
+
/**
|
|
4
|
+
* Every structure loaded and aligned and, with several, superposed: the
|
|
5
|
+
* reset that ends a superposition would undo any framing done before it.
|
|
6
|
+
*/
|
|
7
|
+
export function structuresSettled(host) {
|
|
8
|
+
const { structures, superposedCount } = host;
|
|
9
|
+
return (structures.length > 0 &&
|
|
10
|
+
structures.every(s => !s.loading) &&
|
|
11
|
+
(structures.length < 2 || superposedCount === structures.length));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Moves the camera to the residues. A single residue is also focused, which
|
|
15
|
+
* draws it, its neighbours and their contacts as sticks, as clicking it in 3D
|
|
16
|
+
* does; Mol* focuses one structure at a time, so that is the first target's.
|
|
17
|
+
* `stillCurrent` is asked after Mol* loads, so a plugin replaced meanwhile is
|
|
18
|
+
* left alone.
|
|
19
|
+
*/
|
|
20
|
+
export async function frameResidues(plugin, targets, stillCurrent = () => true) {
|
|
21
|
+
const molstar = await loadMolstar();
|
|
22
|
+
if (stillCurrent()) {
|
|
23
|
+
const loci = targets.map(t => residueLoci(molstar, t));
|
|
24
|
+
plugin.managers.camera.focusLoci(loci);
|
|
25
|
+
if (loci[0] && targets[0]?.labelSeqIds.length === 1) {
|
|
26
|
+
plugin.managers.structure.focus.setFromLoci(loci[0]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
3
30
|
/**
|
|
4
31
|
* Builds the body of the autorun that moves the camera to a declared
|
|
5
32
|
* selection. A session "opened on R248" used to show the whole fold with R248
|
|
6
33
|
* out of sight. It waits until every structure has settled and, with several,
|
|
7
34
|
* until they are superposed, because the reset that ends a superposition would
|
|
8
35
|
* undo it; then it frames the seeded residues once per plugin. Only a spec's
|
|
9
|
-
* seed moves the camera: a click is the user's, and the view
|
|
10
|
-
*
|
|
11
|
-
* contacts as sticks, as clicking it in 3D does. Mol* focuses one structure at
|
|
12
|
-
* a time, so that is the first seeded one.
|
|
36
|
+
* seed or focusResidues moves the camera: a click is the user's, and the view
|
|
37
|
+
* they chose stays.
|
|
13
38
|
*/
|
|
14
39
|
export function makeSelectionFramer(host) {
|
|
15
40
|
let framedPlugin;
|
|
16
41
|
return function frameSeededSelection() {
|
|
17
|
-
const { molstarPluginContext: plugin, structures
|
|
18
|
-
|
|
19
|
-
structures.every(s => !s.loading) &&
|
|
20
|
-
(structures.length < 2 || superposedCount === structures.length);
|
|
21
|
-
if (!plugin || plugin === framedPlugin || !settled) {
|
|
42
|
+
const { molstarPluginContext: plugin, structures } = host;
|
|
43
|
+
if (!plugin || plugin === framedPlugin || !structuresSettled(host)) {
|
|
22
44
|
return;
|
|
23
45
|
}
|
|
24
46
|
// a seed resolved by the same change that settles the structure may land
|
|
25
47
|
// after this run, so the plugin counts as framed only once it has targets
|
|
26
|
-
const targets = structures.flatMap(s => s.
|
|
48
|
+
const targets = structures.flatMap(s => s.seedLit && s.molstarStructure && s.clickedLabelSeqIds.length
|
|
27
49
|
? [
|
|
28
50
|
{
|
|
29
51
|
structure: s.molstarStructure,
|
|
30
52
|
entityId: s.mappedEntity?.entityId,
|
|
31
|
-
labelSeqIds: s.
|
|
53
|
+
labelSeqIds: s.clickedLabelSeqIds,
|
|
32
54
|
},
|
|
33
55
|
]
|
|
34
56
|
: []);
|
|
@@ -36,17 +58,7 @@ export function makeSelectionFramer(host) {
|
|
|
36
58
|
return;
|
|
37
59
|
}
|
|
38
60
|
framedPlugin = plugin;
|
|
39
|
-
|
|
40
|
-
.then(molstar => {
|
|
41
|
-
if (host.molstarPluginContext === plugin) {
|
|
42
|
-
const loci = targets.map(t => residueLoci(molstar, t));
|
|
43
|
-
plugin.managers.camera.focusLoci(loci);
|
|
44
|
-
if (loci[0] && targets[0]?.labelSeqIds.length === 1) {
|
|
45
|
-
plugin.managers.structure.focus.setFromLoci(loci[0]);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
})
|
|
49
|
-
.catch((e) => {
|
|
61
|
+
frameResidues(plugin, targets, () => host.molstarPluginContext === plugin).catch((e) => {
|
|
50
62
|
console.error(e);
|
|
51
63
|
});
|
|
52
64
|
};
|
|
@@ -4,7 +4,7 @@ import { addDisposer, getPath, types } from '@jbrowse/mobx-state-tree';
|
|
|
4
4
|
import { autorun } from 'mobx';
|
|
5
5
|
import { ALIGNMENT_ALGORITHM_VALUES, DEFAULT_ALIGNMENT_ALGORITHM, } from 'p2s_mapper';
|
|
6
6
|
import { COLOR_SCHEME_VALUES, applyColorTheme, } from './applyColorTheme';
|
|
7
|
-
import { makeSelectionFramer } from './frameSelection';
|
|
7
|
+
import { makeSelectionFramer, structuresSettled } from './frameSelection';
|
|
8
8
|
import { makeLociChannel } from './lociChannel';
|
|
9
9
|
import { defaultDisplayName } from './proteinViewSpec';
|
|
10
10
|
import { removeMolstarStructure } from './removeStructure';
|
|
@@ -190,7 +190,7 @@ function stateModelFactory() {
|
|
|
190
190
|
*/
|
|
191
191
|
clearSelection() {
|
|
192
192
|
for (const structure of self.structures) {
|
|
193
|
-
structure.
|
|
193
|
+
structure.setClickedStructureRanges([]);
|
|
194
194
|
structure.setSelectedFeatureId(undefined);
|
|
195
195
|
}
|
|
196
196
|
},
|
|
@@ -305,6 +305,13 @@ function stateModelFactory() {
|
|
|
305
305
|
get showLoading() {
|
|
306
306
|
return showLoading(self);
|
|
307
307
|
},
|
|
308
|
+
/**
|
|
309
|
+
* #getter
|
|
310
|
+
* Every structure loaded, aligned and, with several, superposed.
|
|
311
|
+
*/
|
|
312
|
+
get settled() {
|
|
313
|
+
return structuresSettled(self);
|
|
314
|
+
},
|
|
308
315
|
/**
|
|
309
316
|
* #getter
|
|
310
317
|
* What each still-settling structure is doing, for the canvas overlay.
|
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
import { getSession } from '@jbrowse/core/util';
|
|
2
2
|
import { codingSpans } from '../mappings';
|
|
3
|
-
// The coding spans under
|
|
3
|
+
// The coding spans under structure-residue ranges, through the residues the
|
|
4
4
|
// alignment pairs with the transcript
|
|
5
|
-
function structureRangeSpans(model,
|
|
5
|
+
function structureRangeSpans(model, ranges) {
|
|
6
6
|
const mapping = model.genomeToTranscriptSeqMapping;
|
|
7
7
|
if (!mapping || !model.pairwiseAlignment) {
|
|
8
8
|
return [];
|
|
9
9
|
}
|
|
10
10
|
const transcriptPositions = [];
|
|
11
|
-
for (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
for (const range of ranges) {
|
|
12
|
+
for (let pos = range.start; pos < range.end; pos++) {
|
|
13
|
+
const transcriptPos = model.structureSeqToTranscriptSeqPosition?.[pos];
|
|
14
|
+
if (transcriptPos !== undefined) {
|
|
15
|
+
transcriptPositions.push(transcriptPos);
|
|
16
|
+
}
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
return codingSpans(mapping.p2gCodon, transcriptPositions);
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
|
-
* The genome
|
|
22
|
+
* The genome structure-residue ranges cover, one region per stretch of
|
|
21
23
|
* contiguous coding bases, as a JBrowse highlight takes them. Pure: the caller
|
|
22
24
|
* supplies the assembly and the mapping, so the same conversion serves the
|
|
23
25
|
* hover band, the click band and a test with neither a session nor a connected
|
|
24
26
|
* view.
|
|
25
27
|
*/
|
|
26
|
-
export function
|
|
28
|
+
export function structureRangesToGenomeRegions({ ranges, assemblyName, model, }) {
|
|
27
29
|
const refName = model.genomeToTranscriptSeqMapping?.refName;
|
|
28
|
-
return
|
|
29
|
-
? structureRangeSpans(model,
|
|
30
|
+
return assemblyName && refName
|
|
31
|
+
? structureRangeSpans(model, ranges).map(([start, end]) => ({
|
|
30
32
|
assemblyName,
|
|
31
33
|
refName,
|
|
32
34
|
start,
|
|
@@ -45,10 +47,9 @@ export async function navigateToProteinPosition({ model, structureSeqPos, struct
|
|
|
45
47
|
if (!assemblyName) {
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
48
|
-
const spans = structureRangeSpans(model,
|
|
49
|
-
start: structureSeqPos,
|
|
50
|
-
|
|
51
|
-
});
|
|
50
|
+
const spans = structureRangeSpans(model, [
|
|
51
|
+
{ start: structureSeqPos, end: structureSeqEndPos ?? structureSeqPos + 1 },
|
|
52
|
+
]);
|
|
52
53
|
const start = spans[0]?.[0];
|
|
53
54
|
const end = spans.at(-1)?.[1];
|
|
54
55
|
if (start === undefined || end === undefined) {
|
|
@@ -69,10 +70,9 @@ export async function navigateToProteinPosition({ model, structureSeqPos, struct
|
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
export async function clickProteinToGenome({ model, structureSeqPos, structureSeqEndPos, }) {
|
|
72
|
-
model.
|
|
73
|
-
start: structureSeqPos,
|
|
74
|
-
|
|
75
|
-
});
|
|
73
|
+
model.setClickedStructureRanges([
|
|
74
|
+
{ start: structureSeqPos, end: structureSeqEndPos ?? structureSeqPos + 1 },
|
|
75
|
+
]);
|
|
76
76
|
await navigateToProteinPosition({
|
|
77
77
|
model,
|
|
78
78
|
structureSeqPos,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { residueNumber, transcriptPos } from 'p2s_mapper';
|
|
2
|
+
export function rangeList(ranges) {
|
|
3
|
+
return ranges === undefined ? [] : 'start' in ranges ? [ranges] : ranges;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Collapse a set of positions into sorted, contiguous [start, end) runs.
|
|
7
|
+
*/
|
|
8
|
+
export function positionRuns(positions) {
|
|
9
|
+
const sorted = [...new Set(positions)].sort((a, b) => a - b);
|
|
10
|
+
const runs = [];
|
|
11
|
+
for (const pos of sorted) {
|
|
12
|
+
const last = runs.at(-1);
|
|
13
|
+
if (last?.end === pos) {
|
|
14
|
+
last.end = pos + 1;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
runs.push({ start: pos, end: pos + 1 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return runs;
|
|
21
|
+
}
|
|
22
|
+
function* inclusive({ start, end }) {
|
|
23
|
+
for (let n = start; n <= end; n++) {
|
|
24
|
+
yield n;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The runs of positions whose author number falls in any of the ranges. A
|
|
29
|
+
* fusion numbers its partner apart (2RH1's lysozyme is 1002–1161), so a range
|
|
30
|
+
* across the fusion site selects the receptor on both sides and not the
|
|
31
|
+
* partner between.
|
|
32
|
+
*/
|
|
33
|
+
export function residueRuns(entity, ranges) {
|
|
34
|
+
const list = rangeList(ranges);
|
|
35
|
+
const positions = [];
|
|
36
|
+
for (let pos = 0; pos < (entity?.seq.length ?? 0); pos++) {
|
|
37
|
+
const n = residueNumber(entity, pos);
|
|
38
|
+
if (list.some(r => n >= r.start && n <= r.end)) {
|
|
39
|
+
positions.push(pos);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return positionRuns(positions);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The runs of structure positions the alignment pairs with any of the
|
|
46
|
+
* transcript ranges. A structure residue the transcript lacks, an inserted tag
|
|
47
|
+
* or a fusion partner SIFTS unmaps, splits the run rather than being selected.
|
|
48
|
+
*/
|
|
49
|
+
export function transcriptRuns(mapper, ranges) {
|
|
50
|
+
const positions = [];
|
|
51
|
+
for (const range of rangeList(ranges)) {
|
|
52
|
+
for (const residue of inclusive(range)) {
|
|
53
|
+
const pos = mapper.transcriptToStructure(transcriptPos(residue - 1));
|
|
54
|
+
if (pos !== undefined) {
|
|
55
|
+
positions.push(pos);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return positionRuns(positions);
|
|
60
|
+
}
|
|
61
|
+
export function positionRangeRuns(ranges) {
|
|
62
|
+
const positions = [];
|
|
63
|
+
for (const { start, end } of rangeList(ranges)) {
|
|
64
|
+
for (let pos = start; pos < end; pos++) {
|
|
65
|
+
positions.push(pos);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return positionRuns(positions);
|
|
69
|
+
}
|