diff --git a/packages/web/src/components/Common/HorizontalDragScroll.tsx b/packages/web/src/components/Common/HorizontalDragScroll.tsx new file mode 100644 index 000000000..8859bf9c4 --- /dev/null +++ b/packages/web/src/components/Common/HorizontalDragScroll.tsx @@ -0,0 +1,66 @@ +import React, { PropsWithChildren, useCallback, useState } from 'react' + +import styled from 'styled-components' + +export enum MouseState { + up, + down, + moving, +} + +const Draggable = styled.div<{ $dragged: boolean }>` + cursor: ${(props) => props.$dragged && 'grabbing'}; + user-select: ${(props) => props.$dragged && 'none'}; +` + +export interface DragScrollProps { + onScroll(delta: number): void + onWheel(delta: number): void +} + +export function HorizontalDragScroll({ + children, + onScroll, + onWheel, + ...restProps +}: PropsWithChildren) { + const [mouseState, setMouseState] = useState(MouseState.up) + + const onMouseDown = useCallback(() => setMouseState(MouseState.down), []) + const onMouseUp = useCallback(() => setMouseState(MouseState.up), []) + const dragged = mouseState === MouseState.moving + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + if (mouseState !== MouseState.up) { + setMouseState(MouseState.moving) + const delta = e.movementX + onScroll(delta) + } + }, + [mouseState, onScroll], + ) + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + if (e.shiftKey) { + const delta = e.deltaY + onWheel(delta) + } + }, + [onWheel], + ) + + return ( + + {children} + + ) +} diff --git a/packages/web/src/components/Layout/LayoutResults.tsx b/packages/web/src/components/Layout/LayoutResults.tsx index 8e12433e1..60e158490 100644 --- a/packages/web/src/components/Layout/LayoutResults.tsx +++ b/packages/web/src/components/Layout/LayoutResults.tsx @@ -14,6 +14,7 @@ export const LayoutContainer = styled.div` display: flex; flex-direction: column; flex-wrap: nowrap; + overflow: hidden; ` const Header = styled.header` diff --git a/packages/web/src/components/Results/ResultsTable.tsx b/packages/web/src/components/Results/ResultsTable.tsx index 7113a1f87..8d06f90a9 100644 --- a/packages/web/src/components/Results/ResultsTable.tsx +++ b/packages/web/src/components/Results/ResultsTable.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react' +import React, { ChangeEvent, memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { connect } from 'react-redux' @@ -6,6 +6,7 @@ import { areEqual, FixedSizeList as FixedSizeListBase, FixedSizeListProps, ListC import AutoSizerBase from 'react-virtualized-auto-sizer' import styled from 'styled-components' import { mix, rgba } from 'polished' +import { Col, Container, Label, Row } from 'reactstrap' import { QcStatus } from 'src/algorithms/types' import { ColumnCustomNodeAttr } from 'src/components/Results/ColumnCustomNodeAttr' @@ -17,7 +18,7 @@ import type { SequenceAnalysisState } from 'src/state/algorithm/algorithm.state' import type { State } from 'src/state/reducer' import { SortCategory, SortDirection } from 'src/helpers/sortResults' import { resultsSortByKeyTrigger, resultsSortTrigger } from 'src/state/algorithm/algorithm.actions' -import { setViewedGene } from 'src/state/ui/ui.actions' +import { setViewedGene, setSequenceViewPan, setSequenceViewZoom } from 'src/state/ui/ui.actions' import { ButtonHelp } from './ButtonHelp' import { ColumnClade } from './ColumnClade' @@ -99,6 +100,7 @@ export const TableRow = styled.div<{ even?: boolean; backgroundColor?: string }> align-items: stretch; background-color: ${(props) => props.backgroundColor}; box-shadow: 1px 2px 2px 2px ${rgba('#212529', 0.25)}; + user-select: none; ` export const TableCell = styled.div<{ basis?: string; grow?: number; shrink?: number }>` @@ -280,6 +282,8 @@ const mapStateToProps = (state: State) => ({ resultsFiltered: state.algorithm.resultsFiltered, filterPanelCollapsed: state.ui.filterPanelCollapsed, viewedGene: state.ui.viewedGene, + sequenceViewZoom: state.ui.sequenceView.zoom, + sequenceViewPan: state.ui.sequenceView.pan, }) const mapDispatchToProps = { @@ -339,6 +343,9 @@ const mapDispatchToProps = { resultsSortTrigger({ category: SortCategory.totalStopCodons, direction: SortDirection.desc }), setViewedGene, + + setSequenceViewZoom, + setSequenceViewPan, } export const ResultsTable = React.memo(connect(mapStateToProps, mapDispatchToProps)(ResultsTableDisconnected)) @@ -399,6 +406,14 @@ export interface ResultProps { sortByTotalStopCodonsDesc(): void setViewedGene(viewedGene: string): void + + sequenceViewZoom: number + + setSequenceViewZoom(zoom: number): void + + sequenceViewPan: number + + setSequenceViewPan(zoom: number): void } export function ResultsTableDisconnected({ @@ -432,9 +447,12 @@ export function ResultsTableDisconnected({ sortByTotalStopCodonsDesc, viewedGene, setViewedGene, + sequenceViewZoom, + setSequenceViewZoom, + sequenceViewPan, + setSequenceViewPan, }: ResultProps) { const { t } = useTranslation() - const data = resultsFiltered const rowData: TableRowDatum[] = data.map((datum) => ({ ...datum, @@ -444,8 +462,57 @@ export function ResultsTableDisconnected({ cladeNodeAttrKeys, })) + const handleZoomChange = useCallback( + (event: ChangeEvent) => { + const z = Number.parseInt(event.target.value, 10) / 100 + setSequenceViewZoom(z) + }, + [setSequenceViewZoom], + ) + + const handlePanChange = useCallback( + (event: ChangeEvent) => { + const z = Number.parseInt(event.target.value, 10) / 100 + setSequenceViewPan(z) + }, + [setSequenceViewPan], + ) + return ( <> + + + + + + + + + diff --git a/packages/web/src/components/SequenceView/PeptideView.tsx b/packages/web/src/components/SequenceView/PeptideView.tsx index 72148e929..161b7ba04 100644 --- a/packages/web/src/components/SequenceView/PeptideView.tsx +++ b/packages/web/src/components/SequenceView/PeptideView.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react' +import React, { useCallback, useState } from 'react' import { connect } from 'react-redux' import { ReactResizeDetectorDimensions, withResizeDetector } from 'react-resize-detector' import { Alert as ReactstrapAlert } from 'reactstrap' +import { setSequenceViewPan, setSequenceViewZoom } from 'src/state/ui/ui.actions' import styled from 'styled-components' import type { AnalysisResult, Gene, GeneWarning, Warnings } from 'src/algorithms/types' @@ -76,21 +77,55 @@ export interface PeptideViewProps extends ReactResizeDetectorDimensions { warnings: Warnings geneMap?: Gene[] viewedGene: string + zoom: number + pan: number + setSequenceViewPan(pan: number): void + setSequenceViewZoom(pan: number): void } const mapStateToProps = (state: State) => ({ geneMap: selectGeneMap(state), + zoom: state.ui.sequenceView.zoom, + pan: state.ui.sequenceView.pan, }) -const mapDispatchToProps = {} + +const mapDispatchToProps = { + setSequenceViewPan, + setSequenceViewZoom, +} export const PeptideViewUnsized = connect(mapStateToProps, mapDispatchToProps)(PeptideViewUnsizedDisconnected) -export function PeptideViewUnsizedDisconnected({ width, sequence, warnings, geneMap, viewedGene }: PeptideViewProps) { +export function PeptideViewUnsizedDisconnected({ + sequence, + width, + warnings, + geneMap, + viewedGene, + zoom, + pan, + setSequenceViewPan, + setSequenceViewZoom, +}: PeptideViewProps) { const { t } = useTranslationSafe() + const handleScroll = useCallback( + (delta: number) => { + setSequenceViewPan(pan - 0.001 * delta) + }, + [pan, setSequenceViewPan], + ) + + const handleWheel = useCallback( + (delta: number) => { + setSequenceViewZoom(zoom - 0.0005 * delta) + }, + [zoom, setSequenceViewZoom], + ) + if (!width || !geneMap) { return ( - + ) @@ -99,7 +134,7 @@ export function PeptideViewUnsizedDisconnected({ width, sequence, warnings, gene const gene = geneMap.find((gene) => gene.geneName === viewedGene) if (!gene) { return ( - + {t('Gene {{geneName}} is missing in gene map', { geneName: viewedGene })} ) @@ -108,14 +143,17 @@ export function PeptideViewUnsizedDisconnected({ width, sequence, warnings, gene const warningsForThisGene = warnings.inGenes.filter((warn) => warn.geneName === viewedGene) if (warningsForThisGene.length > 0) { return ( - + ) } const { seqName, unknownAaRanges } = sequence + const zoomedWidth = width * zoom + const pixelPan = pan * width const pixelsPerAa = width / Math.round(gene.length / 3) + const aaSubstitutions = sequence.aaSubstitutions.filter((aaSub) => aaSub.gene === viewedGene) const aaDeletions = sequence.aaDeletions.filter((aaSub) => aaSub.gene === viewedGene) const groups = groupAdjacentAminoacidChanges(aaSubstitutions, aaDeletions) @@ -134,8 +172,8 @@ export function PeptideViewUnsizedDisconnected({ width, sequence, warnings, gene )) return ( - - + + {unknownAaRangesForGene && diff --git a/packages/web/src/components/SequenceView/SequenceView.tsx b/packages/web/src/components/SequenceView/SequenceView.tsx index 01ec82425..affe0fc32 100644 --- a/packages/web/src/components/SequenceView/SequenceView.tsx +++ b/packages/web/src/components/SequenceView/SequenceView.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import { clamp } from 'lodash' +import React, { useCallback } from 'react' import { connect } from 'react-redux' import { ReactResizeDetectorDimensions, withResizeDetector } from 'react-resize-detector' @@ -6,6 +7,8 @@ import styled from 'styled-components' import { selectGenomeSize } from 'src/state/algorithm/algorithm.selectors' import type { State } from 'src/state/reducer' +import { setSequenceViewPan, setSequenceViewZoom } from 'src/state/ui/ui.actions' +import { HorizontalDragScroll } from 'src/components/Common/HorizontalDragScroll' import type { AnalysisResult } from 'src/algorithms/types' import { SequenceMarkerGap } from './SequenceMarkerGap' @@ -14,7 +17,7 @@ import { SequenceMarkerMutation } from './SequenceMarkerMutation' import { SequenceMarkerUnsequencedEnd, SequenceMarkerUnsequencedStart } from './SequenceMarkerUnsequenced' import { SequenceMarkerFrameShift } from './SequenceMarkerFrameShift' -export const SequenceViewWrapper = styled.div` +export const SequenceViewWrapper = styled(HorizontalDragScroll)` display: flex; width: 100%; height: 30px; @@ -28,36 +31,68 @@ export const SequenceViewWrapper = styled.div` export const SequenceViewSVG = styled.svg` padding: 0; margin: 0; - width: 100%; - height: 100%; - margin: 0; - padding: 0; ` export interface SequenceViewProps extends ReactResizeDetectorDimensions { sequence: AnalysisResult genomeSize?: number + zoom: number + pan: number + setSequenceViewPan(pan: number): void + setSequenceViewZoom(pan: number): void } const mapStateToProps = (state: State) => ({ genomeSize: selectGenomeSize(state), + zoom: state.ui.sequenceView.zoom, + pan: state.ui.sequenceView.pan, }) -const mapDispatchToProps = {} + +const mapDispatchToProps = { + setSequenceViewPan, + setSequenceViewZoom, +} export const SequenceViewUnsized = connect(mapStateToProps, mapDispatchToProps)(SequenceViewUnsizedDisconnected) -export function SequenceViewUnsizedDisconnected({ sequence, width, genomeSize }: SequenceViewProps) { +export function SequenceViewUnsizedDisconnected({ + sequence, + width, + genomeSize, + zoom, + pan, + setSequenceViewPan, + setSequenceViewZoom, +}: SequenceViewProps) { const { seqName, substitutions, missing, deletions, alignmentStart, alignmentEnd, frameShifts } = sequence + const handleScroll = useCallback( + (delta: number) => { + const newPan = clamp(pan - 0.001 * delta, -1, 1) + setSequenceViewPan(newPan) + }, + [pan, setSequenceViewPan], + ) + + const handleWheel = useCallback( + (delta: number) => { + const newZoom = clamp(zoom - 0.01 * delta, 1, 5) + setSequenceViewZoom(newZoom) + }, + [zoom, setSequenceViewZoom], + ) + if (!width || !genomeSize) { return ( - + ) } const pixelsPerBase = width / genomeSize + const zoomedWidth = width * (1 / zoom) + const pixelPan = pan * zoomedWidth * (1 / zoom) const mutationViews = substitutions.map((substitution) => { return ( @@ -97,8 +132,8 @@ export function SequenceViewUnsizedDisconnected({ sequence, width, genomeSize }: )) return ( - - + + ('setShowNewRunPopup') export const setViewedGene = action('setViewedGene') export const resetViewedGene = action('resetViewedGene') + +export const setSequenceViewZoom = action('setSequenceViewZoom') + +export const setSequenceViewPan = action('setSequenceViewPan') diff --git a/packages/web/src/state/ui/ui.reducer.ts b/packages/web/src/state/ui/ui.reducer.ts index 340ecf89a..19085fa9d 100644 --- a/packages/web/src/state/ui/ui.reducer.ts +++ b/packages/web/src/state/ui/ui.reducer.ts @@ -5,6 +5,8 @@ import { setIsSettingsDialogOpen, resetViewedGene, setFilterPanelCollapsed, + setSequenceViewPan, + setSequenceViewZoom, setShowNewRunPopup, setShowWhatsnew, setTreeFilterPanelCollapsed, @@ -39,3 +41,11 @@ export const uiReducer = reducerWithInitialState(uiDefaultState) .icase(resetViewedGene, (draft) => { draft.viewedGene = uiDefaultState.viewedGene }) + + .icase(setSequenceViewZoom, (draft, zoom) => { + draft.sequenceView.zoom = zoom + }) + + .icase(setSequenceViewPan, (draft, pan) => { + draft.sequenceView.pan = pan + }) diff --git a/packages/web/src/state/ui/ui.state.ts b/packages/web/src/state/ui/ui.state.ts index db075a92e..402406615 100644 --- a/packages/web/src/state/ui/ui.state.ts +++ b/packages/web/src/state/ui/ui.state.ts @@ -2,6 +2,10 @@ import { GENE_OPTION_NUC_SEQUENCE } from 'src/constants' export interface UiState { isSettingsDialogOpen: boolean + sequenceView: { + zoom: number + pan: number + } filterPanelCollapsed: boolean treeFilterPanelCollapsed: boolean showWhatsnew: boolean @@ -11,6 +15,10 @@ export interface UiState { export const uiDefaultState: UiState = { isSettingsDialogOpen: false, + sequenceView: { + zoom: 1, + pan: 0, + }, filterPanelCollapsed: true, treeFilterPanelCollapsed: true, showWhatsnew: false,