jbrowse-plugin-protein3d 0.5.4 → 0.5.6

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 (39) hide show
  1. package/dist/ProteinView/autoScroll.d.ts +30 -0
  2. package/dist/ProteinView/autoScroll.js +29 -0
  3. package/dist/ProteinView/components/FeatureBar.d.ts +5 -4
  4. package/dist/ProteinView/components/FeatureBar.js +9 -29
  5. package/dist/ProteinView/components/FeatureTypeLabel.d.ts +3 -1
  6. package/dist/ProteinView/components/FeatureTypeLabel.js +13 -3
  7. package/dist/ProteinView/components/ProteinAlignment.js +37 -12
  8. package/dist/ProteinView/components/ProteinFeatureTrack.js +10 -14
  9. package/dist/ProteinView/components/ProteinViewHeader.js +1 -1
  10. package/dist/ProteinView/components/ResidueValueTrack.js +3 -11
  11. package/dist/ProteinView/hooks/useAlignmentColumnHover.d.ts +11 -0
  12. package/dist/ProteinView/hooks/useAlignmentColumnHover.js +23 -0
  13. package/dist/ProteinView/hooks/useProteinFeatureTrackData.d.ts +21 -5
  14. package/dist/ProteinView/hooks/useProteinFeatureTrackData.js +77 -19
  15. package/dist/ProteinView/hooks/useUniProtFeatures.js +3 -3
  16. package/dist/ProteinView/model.d.ts +6 -0
  17. package/dist/ProteinView/model.js +1 -1
  18. package/dist/ProteinView/structureModel.d.ts +10 -0
  19. package/dist/ProteinView/structureModel.js +19 -0
  20. package/dist/jbrowse-plugin-protein3d.umd.production.min.js +13 -13
  21. package/dist/jbrowse-plugin-protein3d.umd.production.min.js.map +4 -4
  22. package/dist/version.d.ts +1 -1
  23. package/dist/version.js +1 -1
  24. package/package.json +1 -1
  25. package/src/ProteinView/autoScroll.test.ts +47 -0
  26. package/src/ProteinView/autoScroll.ts +49 -0
  27. package/src/ProteinView/components/FeatureBar.tsx +13 -39
  28. package/src/ProteinView/components/FeatureTypeLabel.tsx +30 -2
  29. package/src/ProteinView/components/ProteinAlignment.tsx +36 -13
  30. package/src/ProteinView/components/ProteinFeatureTrack.tsx +30 -26
  31. package/src/ProteinView/components/ProteinViewHeader.tsx +1 -1
  32. package/src/ProteinView/components/ResidueValueTrack.tsx +7 -12
  33. package/src/ProteinView/hooks/packLanes.test.ts +57 -0
  34. package/src/ProteinView/hooks/useAlignmentColumnHover.ts +32 -0
  35. package/src/ProteinView/hooks/useProteinFeatureTrackData.ts +98 -26
  36. package/src/ProteinView/hooks/useUniProtFeatures.ts +3 -3
  37. package/src/ProteinView/model.ts +1 -1
  38. package/src/ProteinView/structureModel.ts +18 -0
  39. package/src/version.ts +1 -1
@@ -0,0 +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 declare function largeJumpScrollTarget({ x, scrollLeft, clientWidth, }: {
15
+ x: number;
16
+ scrollLeft: number;
17
+ clientWidth: number;
18
+ }): number | undefined;
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
+ export declare function offScreenCenterTarget({ start, end, scrollLeft, clientWidth, }: {
26
+ start: number;
27
+ end: number;
28
+ scrollLeft: number;
29
+ clientWidth: number;
30
+ }): number | undefined;
@@ -0,0 +1,29 @@
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;
18
+ }
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
+ export function offScreenCenterTarget({ start, end, scrollLeft, clientWidth, }) {
26
+ const viewEnd = scrollLeft + clientWidth;
27
+ const visible = end >= scrollLeft && start <= viewEnd;
28
+ return visible ? undefined : (start + end) / 2 - clientWidth / 2;
29
+ }
@@ -1,8 +1,9 @@
1
1
  import React from 'react';
2
- import type { UniProtFeature } from '../hooks/useUniProtFeatures';
2
+ import type { FeatureLayout } from '../hooks/useProteinFeatureTrackData';
3
3
  import type { JBrowsePluginProteinStructureModel } from '../model';
4
- declare const FeatureBar: ({ feature, model, }: {
5
- feature: UniProtFeature;
4
+ declare const FeatureBar: ({ layout, top, model, }: {
5
+ layout: FeatureLayout;
6
+ top: number;
6
7
  model: JBrowsePluginProteinStructureModel;
7
- }) => React.JSX.Element | null;
8
+ }) => React.JSX.Element;
8
9
  export default FeatureBar;
@@ -2,26 +2,9 @@ import React, { useState } from 'react';
2
2
  import { Tooltip } from '@mui/material';
3
3
  import { observer } from 'mobx-react';
4
4
  import { setMolstarLoci } from '../applyLociInteractivity';
5
- import { CHAR_WIDTH, HOVERED_BORDER, SELECTED_BORDER } from '../constants';
5
+ import { HOVERED_BORDER, SELECTED_BORDER } from '../constants';
6
6
  import { getFeatureColor } from '../hooks/useUniProtFeatures';
7
7
  import { clickProteinToGenome } from '../proteinToGenomeMapping';
8
- /**
9
- * Maps a feature's structure range onto alignment columns, returning both the
10
- * alignment range (for hover) and pixel geometry. Returns undefined when either
11
- * endpoint has no alignment column, so unmappable features aren't drawn at a
12
- * misleading position.
13
- */
14
- function getFeatureLayout(feature, structurePositionToAlignmentMap) {
15
- const start = structurePositionToAlignmentMap?.[feature.start - 1];
16
- const end = structurePositionToAlignmentMap?.[feature.end - 1];
17
- return start === undefined || end === undefined
18
- ? undefined
19
- : {
20
- range: { start, end },
21
- left: start * CHAR_WIDTH,
22
- width: Math.max((end - start + 1) * CHAR_WIDTH, 3),
23
- };
24
- }
25
8
  function FeatureTooltipContent({ feature }) {
26
9
  return (React.createElement("div", null,
27
10
  React.createElement("div", null,
@@ -33,16 +16,17 @@ function FeatureTooltipContent({ feature }) {
33
16
  feature.end),
34
17
  feature.description ? React.createElement("div", null, feature.description) : null));
35
18
  }
36
- const FeatureBar = observer(function FeatureBar({ feature, model, }) {
19
+ const FeatureBar = observer(function FeatureBar({ layout, top, model, }) {
37
20
  const [isHovered, setIsHovered] = useState(false);
38
- const { molstarPluginContext, selectedFeatureId, structurePositionToAlignmentMap, } = model;
21
+ const { molstarPluginContext, selectedFeatureId } = model;
22
+ const { feature, left, width } = layout;
39
23
  const isSelected = selectedFeatureId === feature.uniqueId;
40
- const layout = getFeatureLayout(feature, structurePositionToAlignmentMap);
41
24
  const handleMouseEnter = () => {
42
25
  setIsHovered(true);
43
- if (layout) {
44
- model.setAlignmentHoverRange(layout.range);
45
- }
26
+ model.setAlignmentHoverRange({
27
+ start: layout.alignmentStart,
28
+ end: layout.alignmentEnd,
29
+ });
46
30
  };
47
31
  const handleMouseLeave = () => {
48
32
  setIsHovered(false);
@@ -81,10 +65,6 @@ const FeatureBar = observer(function FeatureBar({ feature, model, }) {
81
65
  model.setClickedStructureRange(undefined);
82
66
  }
83
67
  };
84
- if (!layout) {
85
- return null;
86
- }
87
- const { left, width } = layout;
88
68
  const color = getFeatureColor(feature.type);
89
69
  return (React.createElement(Tooltip, { title: React.createElement(FeatureTooltipContent, { feature: feature }), followCursor: true },
90
70
  React.createElement("div", { "data-testid": `protein-feature-${feature.type}`, "data-feature-id": feature.uniqueId, "data-feature-start": feature.start, "data-feature-end": feature.end, onClick: () => {
@@ -96,7 +76,7 @@ const FeatureBar = observer(function FeatureBar({ feature, model, }) {
96
76
  }, style: {
97
77
  position: 'absolute',
98
78
  left,
99
- top: 0,
79
+ top,
100
80
  width,
101
81
  height: model.trackHeight,
102
82
  backgroundColor: color,
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import type { JBrowsePluginProteinStructureModel } from '../model';
3
- declare const FeatureTypeLabel: ({ type, labelWidth, model, }: {
3
+ declare const FeatureTypeLabel: ({ type, laneCount, expanded, labelWidth, model, }: {
4
4
  type: string;
5
+ laneCount: number;
6
+ expanded: boolean;
5
7
  labelWidth: number;
6
8
  model: JBrowsePluginProteinStructureModel;
7
9
  }) => React.JSX.Element;
@@ -1,12 +1,16 @@
1
1
  import React from 'react';
2
2
  import CloseIcon from '@mui/icons-material/Close';
3
+ import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
4
+ import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
3
5
  import { IconButton, Tooltip } from '@mui/material';
4
6
  import { observer } from 'mobx-react';
5
7
  import { HIDE_BUTTON_COLOR } from '../constants';
6
- const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, labelWidth, model, }) {
8
+ const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, laneCount, expanded, labelWidth, model, }) {
9
+ const lanes = expanded ? laneCount : 1;
10
+ const canExpand = laneCount > 1;
7
11
  return (React.createElement(Tooltip, { title: type, placement: "left" },
8
12
  React.createElement("div", { style: {
9
- height: model.trackHeight + model.trackGap,
13
+ height: lanes * (model.trackHeight + model.trackGap),
10
14
  width: labelWidth - 4,
11
15
  fontSize: 9,
12
16
  fontFamily: 'monospace',
@@ -16,7 +20,7 @@ const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, labelWidth,
16
20
  textOverflow: 'ellipsis',
17
21
  whiteSpace: 'nowrap',
18
22
  display: 'flex',
19
- alignItems: 'center',
23
+ alignItems: expanded ? 'flex-start' : 'center',
20
24
  justifyContent: 'flex-end',
21
25
  gap: 2,
22
26
  } },
@@ -25,6 +29,12 @@ const FeatureTypeLabel = observer(function FeatureTypeLabel({ type, labelWidth,
25
29
  model.hideFeatureType(type);
26
30
  }, title: `Hide ${type} track`, sx: { p: 0, color: HIDE_BUTTON_COLOR } },
27
31
  React.createElement(CloseIcon, { sx: { fontSize: model.trackHeight } })),
32
+ canExpand ? (React.createElement(IconButton, { onClick: e => {
33
+ e.stopPropagation();
34
+ model.toggleFeatureTypeExpanded(type);
35
+ }, title: expanded
36
+ ? `Collapse ${type} track`
37
+ : `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,
28
38
  React.createElement("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, type))));
29
39
  });
30
40
  export default FeatureTypeLabel;
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
2
2
  import { Tooltip, Typography } from '@mui/material';
3
3
  import { autorun } from 'mobx';
4
4
  import { observer } from 'mobx-react';
5
+ import { largeJumpScrollTarget, offScreenCenterTarget } from '../autoScroll';
5
6
  import { CHAR_WIDTH, LABEL_WIDTH, ROW_HEIGHT } from '../constants';
6
7
  import ProteinAlignmentHelpButton from './ProteinAlignmentHelpButton';
7
8
  import { ProteinFeatureTrackContent, ProteinFeatureTrackLabels, } from './ProteinFeatureTrack';
@@ -27,31 +28,55 @@ function GutterLabel({ label, title, height, }) {
27
28
  const ProteinAlignment = observer(function ProteinAlignment({ model, }) {
28
29
  const { pairwiseAlignment, showHighlight, showProteinTracks, uniprotId, confidenceCells, hydrophobicityCells, } = model;
29
30
  const containerRef = useRef(null);
30
- const scrolledToSelectionRef = useRef(false);
31
+ const lastScrolledSelectionRef = useRef(undefined);
31
32
  const { data: featureData, isLoading: featureLoading, error: featureError, } = useProteinFeatureTrackData(model, uniprotId);
33
+ // Recenter only on a large jump — when the hovered column lands well outside
34
+ // the viewport (e.g. hovering a distant residue in the 3D structure). A column
35
+ // that has merely edged just off-screen during a continuous hover sweep is
36
+ // left alone, so the panel doesn't feel like it's constantly re-centering.
32
37
  useEffect(() => autorun(() => {
33
38
  const container = containerRef.current;
34
39
  if (model.autoScrollAlignment &&
35
40
  !model.isMouseInAlignment &&
36
41
  model.alignmentHoverPos !== undefined &&
37
42
  container) {
38
- container.scrollTo({
39
- left: model.alignmentHoverPos * CHAR_WIDTH - container.clientWidth / 2,
40
- behavior: 'smooth',
43
+ const target = largeJumpScrollTarget({
44
+ x: model.alignmentHoverPos * CHAR_WIDTH,
45
+ scrollLeft: container.scrollLeft,
46
+ clientWidth: container.clientWidth,
41
47
  });
48
+ if (target !== undefined) {
49
+ container.scrollTo({ left: target, behavior: 'smooth' });
50
+ }
42
51
  }
43
52
  }), [model]);
44
- // Scroll the persistent selection into view once it first resolves, so a
45
- // declarative `initialSelection` (or any off-screen selection) shows its
46
- // highlight band instead of opening scrolled to the N-terminus. Guarded to
47
- // fire once, so it doesn't fight the user's own scrolling afterward.
53
+ // Scroll a selection into view when it changes to an off-screen range — both
54
+ // the declarative `initialSelection` on open and a later click on a distant
55
+ // feature bar, which would otherwise select something the user can't see.
56
+ // Keyed on the range so it fires once per distinct selection and doesn't fight
57
+ // the user's own scrolling afterward.
48
58
  useEffect(() => autorun(() => {
49
59
  const container = containerRef.current;
50
60
  const range = model.clickAlignmentRange;
51
- if (container && range && !scrolledToSelectionRef.current) {
52
- scrolledToSelectionRef.current = true;
53
- const mid = ((range.start + range.end) / 2) * CHAR_WIDTH;
54
- container.scrollTo({ left: mid - container.clientWidth / 2 });
61
+ if (container) {
62
+ if (range) {
63
+ const key = `${range.start}-${range.end}`;
64
+ if (key !== lastScrolledSelectionRef.current) {
65
+ lastScrolledSelectionRef.current = key;
66
+ const target = offScreenCenterTarget({
67
+ start: range.start * CHAR_WIDTH,
68
+ end: (range.end + 1) * CHAR_WIDTH,
69
+ scrollLeft: container.scrollLeft,
70
+ clientWidth: container.clientWidth,
71
+ });
72
+ if (target !== undefined) {
73
+ container.scrollTo({ left: target, behavior: 'smooth' });
74
+ }
75
+ }
76
+ }
77
+ else {
78
+ lastScrolledSelectionRef.current = undefined;
79
+ }
55
80
  }
56
81
  }), [model]);
57
82
  if (!pairwiseAlignment) {
@@ -4,27 +4,23 @@ import { CHAR_WIDTH } from '../constants';
4
4
  import FeatureBar from './FeatureBar';
5
5
  import FeatureTypeLabel from './FeatureTypeLabel';
6
6
  import HoverMarker from './HoverMarker';
7
- const FeatureTypeTrackContent = observer(function FeatureTypeTrackContent({ features, model, sequenceLength, }) {
7
+ import useAlignmentColumnHover from '../hooks/useAlignmentColumnHover';
8
+ const FeatureTypeTrackContent = observer(function FeatureTypeTrackContent({ group, model, sequenceLength, expanded, }) {
9
+ const lanes = expanded ? group.laneCount : 1;
10
+ const laneUnit = model.trackHeight + model.trackGap;
8
11
  return (React.createElement("div", { style: {
9
12
  position: 'relative',
10
- height: model.trackHeight,
13
+ height: lanes * model.trackHeight + (lanes - 1) * model.trackGap,
11
14
  width: sequenceLength * CHAR_WIDTH,
12
15
  marginBottom: model.trackGap,
13
- } }, features.map(feature => (React.createElement(FeatureBar, { key: feature.uniqueId, feature: feature, model: model })))));
16
+ } }, group.layouts.map(layout => (React.createElement(FeatureBar, { key: layout.feature.uniqueId, layout: layout, top: (expanded ? layout.lane : 0) * laneUnit, model: model })))));
14
17
  });
15
18
  export const ProteinFeatureTrackLabels = observer(function ProteinFeatureTrackLabels({ data, labelWidth, model, }) {
16
- return (React.createElement(React.Fragment, null, data.visibleTypes.map(type => (React.createElement(FeatureTypeLabel, { key: type, type: type, labelWidth: labelWidth, model: model })))));
19
+ 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
20
  });
18
21
  export const ProteinFeatureTrackContent = observer(function ProteinFeatureTrackContent({ data, model, }) {
19
- return (React.createElement("div", { style: { position: 'relative' }, onMouseMove: (e) => {
20
- const rect = e.currentTarget.getBoundingClientRect();
21
- const alignmentPos = Math.floor((e.clientX - rect.left) / CHAR_WIDTH);
22
- if (alignmentPos >= 0 && alignmentPos < data.sequenceLength) {
23
- model.hoverAlignmentPosition(alignmentPos);
24
- }
25
- }, onMouseLeave: () => {
26
- model.setHoveredPosition(undefined);
27
- } },
28
- data.visibleTypes.map(type => (React.createElement(FeatureTypeTrackContent, { key: type, features: data.groupedFeatures[type], model: model, sequenceLength: data.sequenceLength }))),
22
+ const hoverHandlers = useAlignmentColumnHover(model, data.sequenceLength);
23
+ return (React.createElement("div", { style: { position: 'relative' }, ...hoverHandlers },
24
+ data.visibleGroups.map(group => (React.createElement(FeatureTypeTrackContent, { key: group.type, group: group, model: model, sequenceLength: data.sequenceLength, expanded: model.expandedFeatureTypes.has(group.type) }))),
29
25
  React.createElement(HoverMarker, { model: model })));
30
26
  });
@@ -47,7 +47,7 @@ function getDisplayToggles(model) {
47
47
  },
48
48
  },
49
49
  {
50
- label: 'Auto-scroll features',
50
+ label: 'Auto-scroll alignment',
51
51
  checked: model.autoScrollAlignment,
52
52
  onToggle: () => {
53
53
  model.setAutoScrollAlignment(!model.autoScrollAlignment);
@@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react';
2
2
  import { Tooltip } from '@mui/material';
3
3
  import { observer } from 'mobx-react';
4
4
  import { CHAR_WIDTH } from '../constants';
5
+ import useAlignmentColumnHover from '../hooks/useAlignmentColumnHover';
5
6
  /**
6
7
  * A per-residue scalar track (e.g. pLDDT, hydrophobicity) rendered as colored
7
8
  * cells aligned to the pairwise-alignment columns, matching the UniProt feature
@@ -17,23 +18,14 @@ const ResidueValueTrack = observer(function ResidueValueTrack({ cells, colorFor,
17
18
  return map;
18
19
  }, [cells]);
19
20
  const hoveredValue = hoveredCol === undefined ? undefined : valueByCol.get(hoveredCol);
21
+ const hoverHandlers = useAlignmentColumnHover(model, sequenceLength, setHoveredCol);
20
22
  return (React.createElement(Tooltip, { title: hoveredValue === undefined ? '' : formatValue(hoveredValue), followCursor: true },
21
23
  React.createElement("div", { style: {
22
24
  position: 'relative',
23
25
  height: model.trackHeight,
24
26
  width: sequenceLength * CHAR_WIDTH,
25
27
  marginBottom: model.trackGap,
26
- }, onMouseMove: (e) => {
27
- const rect = e.currentTarget.getBoundingClientRect();
28
- const alignmentPos = Math.floor((e.clientX - rect.left) / CHAR_WIDTH);
29
- setHoveredCol(alignmentPos);
30
- if (alignmentPos >= 0 && alignmentPos < sequenceLength) {
31
- model.hoverAlignmentPosition(alignmentPos);
32
- }
33
- }, onMouseLeave: () => {
34
- setHoveredCol(undefined);
35
- model.setHoveredPosition(undefined);
36
- } }, cells.map(cell => (React.createElement("div", { key: cell.col, style: {
28
+ }, ...hoverHandlers }, cells.map(cell => (React.createElement("div", { key: cell.col, style: {
37
29
  position: 'absolute',
38
30
  left: cell.col * CHAR_WIDTH,
39
31
  top: 0,
@@ -0,0 +1,11 @@
1
+ import type React from 'react';
2
+ import type { JBrowsePluginProteinStructureModel } from '../model';
3
+ /**
4
+ * Shared mouse handlers for tracks that map cursor x-position to an alignment
5
+ * column and drive the structure hover. Optionally reports the hovered column
6
+ * (used by tracks that show a per-column tooltip).
7
+ */
8
+ export default function useAlignmentColumnHover(model: JBrowsePluginProteinStructureModel, sequenceLength: number, onCol?: (col: number | undefined) => void): {
9
+ onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
10
+ onMouseLeave: () => void;
11
+ };
@@ -0,0 +1,23 @@
1
+ import { CHAR_WIDTH } from '../constants';
2
+ /**
3
+ * Shared mouse handlers for tracks that map cursor x-position to an alignment
4
+ * column and drive the structure hover. Optionally reports the hovered column
5
+ * (used by tracks that show a per-column tooltip).
6
+ */
7
+ export default function useAlignmentColumnHover(model, sequenceLength, onCol) {
8
+ return {
9
+ onMouseMove: (e) => {
10
+ const rect = e.currentTarget.getBoundingClientRect();
11
+ const col = Math.floor((e.clientX - rect.left) / CHAR_WIDTH);
12
+ const inRange = col >= 0 && col < sequenceLength;
13
+ onCol?.(inRange ? col : undefined);
14
+ if (inRange) {
15
+ model.hoverAlignmentPosition(col);
16
+ }
17
+ },
18
+ onMouseLeave: () => {
19
+ onCol?.(undefined);
20
+ model.setHoveredPosition(undefined);
21
+ },
22
+ };
23
+ }
@@ -1,15 +1,31 @@
1
1
  import type { UniProtFeature } from './useUniProtFeatures';
2
2
  import type { JBrowsePluginProteinStructureModel } from '../model';
3
- type FeaturesByType = Record<string, UniProtFeature[]>;
3
+ export interface FeatureLayout {
4
+ feature: UniProtFeature;
5
+ alignmentStart: number;
6
+ alignmentEnd: number;
7
+ left: number;
8
+ width: number;
9
+ lane: number;
10
+ }
11
+ export interface FeatureGroup {
12
+ type: string;
13
+ layouts: FeatureLayout[];
14
+ laneCount: number;
15
+ }
4
16
  export interface FeatureTrackData {
5
- featureTypes: string[];
6
- visibleTypes: string[];
7
- groupedFeatures: FeaturesByType;
17
+ visibleGroups: FeatureGroup[];
8
18
  sequenceLength: number;
9
19
  }
20
+ /**
21
+ * Greedy interval packing: assigns each feature the first lane whose last
22
+ * feature ends before this one starts, so overlapping features of the same type
23
+ * stack instead of hiding each other. Mutates each layout's lane and returns the
24
+ * lane count (at least 1).
25
+ */
26
+ export declare function packLanes(layouts: FeatureLayout[]): number;
10
27
  export default function useProteinFeatureTrackData(model: JBrowsePluginProteinStructureModel, uniprotId: string | undefined): {
11
28
  data: FeatureTrackData | undefined;
12
29
  isLoading: boolean;
13
30
  error: unknown;
14
31
  };
15
- export {};
@@ -1,25 +1,83 @@
1
+ import { useMemo } from 'react';
2
+ import { CHAR_WIDTH } from '../constants';
1
3
  import useUniProtFeatures from './useUniProtFeatures';
2
- function groupFeaturesByType(features) {
3
- const grouped = {};
4
- for (const feature of features) {
5
- grouped[feature.type] ??= [];
6
- grouped[feature.type].push(feature);
4
+ /**
5
+ * Maps a feature's structure range onto alignment columns and pixel geometry.
6
+ * Returns undefined when either endpoint has no alignment column, so unmappable
7
+ * features aren't drawn at a misleading position.
8
+ */
9
+ function layoutFeature(feature, structurePositionToAlignmentMap) {
10
+ const alignmentStart = structurePositionToAlignmentMap[feature.start - 1];
11
+ const alignmentEnd = structurePositionToAlignmentMap[feature.end - 1];
12
+ return alignmentStart === undefined || alignmentEnd === undefined
13
+ ? undefined
14
+ : {
15
+ feature,
16
+ alignmentStart,
17
+ alignmentEnd,
18
+ left: alignmentStart * CHAR_WIDTH,
19
+ width: Math.max((alignmentEnd - alignmentStart + 1) * CHAR_WIDTH, 3),
20
+ lane: 0,
21
+ };
22
+ }
23
+ /**
24
+ * Greedy interval packing: assigns each feature the first lane whose last
25
+ * feature ends before this one starts, so overlapping features of the same type
26
+ * stack instead of hiding each other. Mutates each layout's lane and returns the
27
+ * lane count (at least 1).
28
+ */
29
+ export function packLanes(layouts) {
30
+ const laneEnds = [];
31
+ const sorted = [...layouts].sort((a, b) => a.alignmentStart - b.alignmentStart);
32
+ for (const layout of sorted) {
33
+ const free = laneEnds.findIndex(end => end < layout.alignmentStart);
34
+ if (free === -1) {
35
+ layout.lane = laneEnds.length;
36
+ laneEnds.push(layout.alignmentEnd);
37
+ }
38
+ else {
39
+ layout.lane = free;
40
+ laneEnds[free] = layout.alignmentEnd;
41
+ }
7
42
  }
8
- return grouped;
43
+ return Math.max(laneEnds.length, 1);
9
44
  }
10
45
  export default function useProteinFeatureTrackData(model, uniprotId) {
11
46
  const { features, isLoading, error } = useUniProtFeatures(uniprotId);
12
- const { pairwiseAlignment, hiddenFeatureTypes } = model;
13
- if (!uniprotId || isLoading || error || !features || !pairwiseAlignment) {
14
- return { data: undefined, isLoading, error };
15
- }
16
- const sequenceLength = pairwiseAlignment.alns[0].seq.length;
17
- const groupedFeatures = groupFeaturesByType(features);
18
- const featureTypes = Object.keys(groupedFeatures);
19
- const visibleTypes = featureTypes.filter(type => !hiddenFeatureTypes.has(type));
20
- return {
21
- data: { featureTypes, visibleTypes, groupedFeatures, sequenceLength },
22
- isLoading: false,
23
- error: undefined,
24
- };
47
+ const { pairwiseAlignment, hiddenFeatureTypes, structurePositionToAlignmentMap } = model;
48
+ const data = useMemo(() => {
49
+ if (!features || !pairwiseAlignment || !structurePositionToAlignmentMap) {
50
+ return undefined;
51
+ }
52
+ const groups = new Map();
53
+ for (const feature of features) {
54
+ if (!hiddenFeatureTypes.has(feature.type)) {
55
+ const layout = layoutFeature(feature, structurePositionToAlignmentMap);
56
+ if (layout) {
57
+ const list = groups.get(feature.type);
58
+ if (list) {
59
+ list.push(layout);
60
+ }
61
+ else {
62
+ groups.set(feature.type, [layout]);
63
+ }
64
+ }
65
+ }
66
+ }
67
+ const visibleGroups = [...groups].map(([type, layouts]) => ({
68
+ type,
69
+ layouts,
70
+ laneCount: packLanes(layouts),
71
+ }));
72
+ return {
73
+ visibleGroups,
74
+ sequenceLength: pairwiseAlignment.alns[0].seq.length,
75
+ };
76
+ }, [
77
+ features,
78
+ pairwiseAlignment,
79
+ hiddenFeatureTypes,
80
+ structurePositionToAlignmentMap,
81
+ ]);
82
+ return { data, isLoading, error };
25
83
  }
@@ -55,8 +55,8 @@ async function fetchUniProtFeatures(url) {
55
55
  continue;
56
56
  }
57
57
  const type = parts[2];
58
- const start = Number.parseInt(parts[3] ?? '0', 10);
59
- const end = Number.parseInt(parts[4] ?? '0', 10);
58
+ const start = Number.parseInt(parts[3] ?? '', 10);
59
+ const end = Number.parseInt(parts[4] ?? '', 10);
60
60
  const attributes = parts[8] ?? '';
61
61
  let description = '';
62
62
  let id;
@@ -69,7 +69,7 @@ async function fetchUniProtFeatures(url) {
69
69
  id = value;
70
70
  }
71
71
  }
72
- if (type) {
72
+ if (type && start >= 1 && end >= start) {
73
73
  const uniqueId = `${type}-${start}-${end}-${features.length}`;
74
74
  features.push({
75
75
  type,
@@ -63,6 +63,7 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
63
63
  } | undefined;
64
64
  selectedFeatureId: string | undefined;
65
65
  hiddenFeatureTypes: Set<string>;
66
+ expandedFeatureTypes: Set<string>;
66
67
  } & {
67
68
  setStructureData(data: {
68
69
  entities?: import("./extractStructureSequences").Entity[];
@@ -71,6 +72,7 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
71
72
  hideFeatureType(type: string): void;
72
73
  showFeatureType(type: string): void;
73
74
  showAllFeatureTypes(): void;
75
+ toggleFeatureTypeExpanded(type: string): void;
74
76
  setLoadedToMolstar(val: boolean): void;
75
77
  } & {
76
78
  readonly connectedView: (import("@jbrowse/mobx-state-tree").ModelInstanceTypeProps<{
@@ -819,6 +821,7 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
819
821
  } | undefined;
820
822
  selectedFeatureId: string | undefined;
821
823
  hiddenFeatureTypes: Set<string>;
824
+ expandedFeatureTypes: Set<string>;
822
825
  } & {
823
826
  setStructureData(data: {
824
827
  entities?: import("./extractStructureSequences").Entity[];
@@ -827,6 +830,7 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
827
830
  hideFeatureType(type: string): void;
828
831
  showFeatureType(type: string): void;
829
832
  showAllFeatureTypes(): void;
833
+ toggleFeatureTypeExpanded(type: string): void;
830
834
  setLoadedToMolstar(val: boolean): void;
831
835
  } & {
832
836
  readonly connectedView: (import("@jbrowse/mobx-state-tree").ModelInstanceTypeProps<{
@@ -1454,6 +1458,7 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
1454
1458
  } | undefined;
1455
1459
  selectedFeatureId: string | undefined;
1456
1460
  hiddenFeatureTypes: Set<string>;
1461
+ expandedFeatureTypes: Set<string>;
1457
1462
  } & {
1458
1463
  setStructureData(data: {
1459
1464
  entities?: import("./extractStructureSequences").Entity[];
@@ -1462,6 +1467,7 @@ declare function stateModelFactory(): import("@jbrowse/mobx-state-tree").IModelT
1462
1467
  hideFeatureType(type: string): void;
1463
1468
  showFeatureType(type: string): void;
1464
1469
  showAllFeatureTypes(): void;
1470
+ toggleFeatureTypeExpanded(type: string): void;
1465
1471
  setLoadedToMolstar(val: boolean): void;
1466
1472
  } & {
1467
1473
  readonly connectedView: (import("@jbrowse/mobx-state-tree").ModelInstanceTypeProps<{
@@ -426,7 +426,7 @@ function stateModelFactory() {
426
426
  },
427
427
  },
428
428
  {
429
- label: 'Auto-scroll protein feature view on hover',
429
+ label: 'Auto-scroll alignment to hovered position',
430
430
  type: 'checkbox',
431
431
  checked: self.autoScrollAlignment,
432
432
  onClick: () => {
@@ -134,6 +134,12 @@ declare const Structure: import("@jbrowse/mobx-state-tree").IModelType<{
134
134
  * Set of feature track types that are hidden
135
135
  */
136
136
  hiddenFeatureTypes: Set<string>;
137
+ /**
138
+ * #volatile
139
+ * Set of feature track types expanded to show every overlapping feature on
140
+ * its own lane (collapsed types draw all features on a single row)
141
+ */
142
+ expandedFeatureTypes: Set<string>;
137
143
  } & {
138
144
  setStructureData(data: {
139
145
  entities?: Entity[];
@@ -151,6 +157,10 @@ declare const Structure: import("@jbrowse/mobx-state-tree").IModelType<{
151
157
  * #action
152
158
  */
153
159
  showAllFeatureTypes(): void;
160
+ /**
161
+ * #action
162
+ */
163
+ toggleFeatureTypeExpanded(type: string): void;
154
164
  /**
155
165
  * #action
156
166
  */