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.
Files changed (52) hide show
  1. package/dist/LaunchProteinView/components/FoldseekSearch.js +7 -3
  2. package/dist/LaunchProteinView/services/foldseekApi.js +17 -6
  3. package/dist/ProteinView/autoScroll.js +24 -23
  4. package/dist/ProteinView/components/FeatureBar.js +1 -6
  5. package/dist/ProteinView/components/FeatureTypeLabel.js +2 -5
  6. package/dist/ProteinView/components/ProteinAlignment.js +14 -33
  7. package/dist/ProteinView/components/ProteinFeatureTrack.js +2 -2
  8. package/dist/ProteinView/components/ProteinViewHeader.js +1 -1
  9. package/dist/ProteinView/components/SplitString.js +3 -21
  10. package/dist/ProteinView/entityAlignedTo.js +37 -0
  11. package/dist/ProteinView/frameSelection.js +34 -22
  12. package/dist/ProteinView/model.js +9 -2
  13. package/dist/ProteinView/proteinToGenomeMapping.js +18 -18
  14. package/dist/ProteinView/residueRanges.js +69 -0
  15. package/dist/ProteinView/structureModel.js +229 -81
  16. package/dist/ProteinView/subscribeMolstarInteraction.js +5 -8
  17. package/dist/ProteinView/viewInteractions.js +9 -7
  18. package/dist/jbrowse-plugin-protein3d.umd.production.min.js +15 -15
  19. package/dist/jbrowse-plugin-protein3d.umd.production.min.js.map +4 -4
  20. package/dist/version.js +1 -1
  21. package/package.json +1 -1
  22. package/src/LaunchProteinView/components/FoldseekSearch.tsx +11 -2
  23. package/src/LaunchProteinView/services/foldseekApi.test.ts +28 -0
  24. package/src/LaunchProteinView/services/foldseekApi.ts +23 -10
  25. package/src/LaunchProteinViewExtensionPoint/index.ts +10 -9
  26. package/src/ProteinView/autoScroll.test.ts +68 -13
  27. package/src/ProteinView/autoScroll.ts +47 -23
  28. package/src/ProteinView/components/FeatureBar.tsx +1 -6
  29. package/src/ProteinView/components/FeatureTypeLabel.tsx +1 -8
  30. package/src/ProteinView/components/ProteinAlignment.tsx +14 -43
  31. package/src/ProteinView/components/ProteinFeatureTrack.tsx +0 -3
  32. package/src/ProteinView/components/ProteinViewHeader.tsx +1 -0
  33. package/src/ProteinView/components/SplitString.tsx +12 -27
  34. package/src/ProteinView/entityAlignedTo.test.ts +56 -0
  35. package/src/ProteinView/entityAlignedTo.ts +60 -0
  36. package/src/ProteinView/frameSelection.test.ts +20 -6
  37. package/src/ProteinView/frameSelection.ts +67 -35
  38. package/src/ProteinView/geneExplorerLinkage.test.ts +16 -6
  39. package/src/ProteinView/model.test.ts +9 -7
  40. package/src/ProteinView/model.ts +9 -2
  41. package/src/ProteinView/proteinToGenomeMapping.ts +22 -21
  42. package/src/ProteinView/proteinViewSpec.ts +6 -4
  43. package/src/ProteinView/residueRanges.test.ts +100 -0
  44. package/src/ProteinView/residueRanges.ts +98 -0
  45. package/src/ProteinView/structureModel.test.ts +321 -13
  46. package/src/ProteinView/structureModel.ts +274 -107
  47. package/src/ProteinView/subscribeMolstarInteraction.test.ts +32 -0
  48. package/src/ProteinView/subscribeMolstarInteraction.ts +24 -2
  49. package/src/ProteinView/viewInteractions.test.ts +30 -13
  50. package/src/ProteinView/viewInteractions.ts +8 -7
  51. package/src/version.ts +1 -1
  52. 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 canSearch = sequence.trim().length > 0 && selectedDatabases.length > 0 && !isBusy;
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 async function predict3Di({ aaSequence, signal, }) {
17
- // Clean the sequence - remove FASTA header, whitespace, stop codons, and non-AA chars
18
- const cleanSequence = aaSequence
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, ''); // Keep only valid amino acids
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
- * Pure geometry for the alignment panel's auto-scrolling, extracted so it can be
3
- * unit-tested independently of the DOM/mobx effects that drive it. All values
4
- * are in pixels within the horizontally-scrolling alignment container.
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.setClickedStructureRange(undefined);
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, labelWidth, model, }) {
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", { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, type))));
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 { largeJumpScrollTarget, offScreenCenterTarget } from '../autoScroll';
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
- // Recenter only on a large jump — when the hovered column lands well outside
97
- // the viewport (e.g. hovering a distant residue in the 3D structure). A column
98
- // that has merely edged just off-screen during a continuous hover sweep is
99
- // left alone, so the panel doesn't feel like it's constantly re-centering.
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
- if (model.autoScrollAlignment &&
103
- !model.isMouseInAlignment &&
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 = `${range.start}-${range.end}`;
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.scrollTo({ left: target, behavior: 'smooth' });
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.setIsMouseInAlignment(false);
177
- model.setHoveredPosition(undefined);
158
+ model.leaveAlignment();
178
159
  } },
179
160
  React.createElement("div", { style: {
180
161
  flexShrink: 0,
181
- width: LABEL_WIDTH,
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, labelWidth: LABEL_WIDTH, model: model })) : null) : null,
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, labelWidth, 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), labelWidth: labelWidth, model: model })))));
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
- : matchRuns(alignmentMatchSet).map(run => (React.createElement("span", { key: run.start, style: {
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, { range: model.clickAlignmentRange, strLength: strLength, background: "rgba(0, 120, 255, 0.3)", border: "1px solid rgba(0, 120, 255, 0.6)", height: height }),
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 they chose stays.
10
- * A one-residue seed is also focused, which draws it, its neighbours and their
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, superposedCount } = host;
18
- const settled = structures.length > 0 &&
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.seededSelection && s.molstarStructure && s.selectLabelSeqIds.length
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.selectLabelSeqIds,
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
- loadMolstar()
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.setClickedStructureRange(undefined);
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 a structure-residue range, through the residues the
3
+ // The coding spans under structure-residue ranges, through the residues the
4
4
  // alignment pairs with the transcript
5
- function structureRangeSpans(model, range) {
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 (let pos = range.start; pos < range.end; pos++) {
12
- const transcriptPos = model.structureSeqToTranscriptSeqPosition?.[pos];
13
- if (transcriptPos !== undefined) {
14
- transcriptPositions.push(transcriptPos);
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 a structure-residue range covers, one region per stretch of
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 structureRangeToGenomeRegions({ range, assemblyName, model, }) {
28
+ export function structureRangesToGenomeRegions({ ranges, assemblyName, model, }) {
27
29
  const refName = model.genomeToTranscriptSeqMapping?.refName;
28
- return range && assemblyName && refName
29
- ? structureRangeSpans(model, range).map(([start, end]) => ({
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
- end: structureSeqEndPos ?? structureSeqPos + 1,
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.setClickedStructureRange({
73
- start: structureSeqPos,
74
- end: structureSeqEndPos ?? structureSeqPos + 1,
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
+ }