Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sequence view pan and zoom #295

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
66 changes: 66 additions & 0 deletions packages/web/src/components/Common/HorizontalDragScroll.tsx
Original file line number Diff line number Diff line change
@@ -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<DragScrollProps>) {
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<HTMLDivElement>) => {
if (mouseState !== MouseState.up) {
setMouseState(MouseState.moving)
const delta = e.movementX
onScroll(delta)
}
},
[mouseState, onScroll],
)

const handleWheel = useCallback(
(e: React.WheelEvent<HTMLDivElement>) => {
if (e.shiftKey) {
const delta = e.deltaY
onWheel(delta)
}
},
[onWheel],
)

return (
<Draggable
$dragged={dragged}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={onMouseMove}
onWheel={handleWheel}
{...restProps}
>
{children}
</Draggable>
)
}
1 change: 1 addition & 0 deletions packages/web/src/components/Layout/LayoutResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const LayoutContainer = styled.div`
display: flex;
flex-direction: column;
flex-wrap: nowrap;
overflow: hidden;
`

const Header = styled.header`
Expand Down
73 changes: 70 additions & 3 deletions packages/web/src/components/Results/ResultsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { memo } from 'react'
import React, { ChangeEvent, memo, useCallback } from 'react'

import { useTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import { areEqual, FixedSizeList as FixedSizeListBase, FixedSizeListProps, ListChildComponentProps } from 'react-window'
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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 }>`
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand All @@ -444,8 +462,57 @@ export function ResultsTableDisconnected({
cladeNodeAttrKeys,
}))

const handleZoomChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const z = Number.parseInt(event.target.value, 10) / 100
setSequenceViewZoom(z)
},
[setSequenceViewZoom],
)

const handlePanChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const z = Number.parseInt(event.target.value, 10) / 100
setSequenceViewPan(z)
},
[setSequenceViewPan],
)

return (
<>
<Container fluid className="d-flex w-100">
<Row noGutters className="d-flex ml-auto">
<Col className="ml-auto">
<Label style={{ margin: '20px' }}>
<span style={{ width: '100px' }}>{'Pan'}</span>
<input
type="range"
style={{ margin: '20px', width: '200px' }}
min={-100}
max={100}
value={sequenceViewPan * 100}
onChange={handlePanChange}
onAuxClick={() => setSequenceViewPan(0)}
/>
<span style={{ width: '100px' }}>{sequenceViewPan.toFixed(3)}</span>
</Label>
<Label style={{ margin: '20px' }}>
<span style={{ width: '100px' }}>{'Zoom'}</span>
<input
type="range"
style={{ margin: '20px', width: '200px' }}
min={100}
max={500}
value={sequenceViewZoom * 100}
onChange={handleZoomChange}
onAuxClick={() => setSequenceViewZoom(1)}
/>
<span style={{ width: '100px' }}>{sequenceViewZoom.toFixed(3)}</span>
</Label>
</Col>
</Row>
</Container>

<Table rounded={!filterPanelCollapsed}>
<TableHeaderRow>
<TableHeaderCell first basis={columnWidthsPx.id} grow={0} shrink={0}>
Expand Down
54 changes: 46 additions & 8 deletions packages/web/src/components/SequenceView/PeptideView.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 (
<SequenceViewWrapper>
<SequenceViewWrapper onScroll={handleScroll} onWheel={handleWheel}>
<SequenceViewSVG fill="transparent" viewBox={`0 0 10 10`} />
</SequenceViewWrapper>
)
Expand All @@ -99,7 +134,7 @@ export function PeptideViewUnsizedDisconnected({ width, sequence, warnings, gene
const gene = geneMap.find((gene) => gene.geneName === viewedGene)
if (!gene) {
return (
<SequenceViewWrapper>
<SequenceViewWrapper onScroll={handleScroll} onWheel={handleWheel}>
{t('Gene {{geneName}} is missing in gene map', { geneName: viewedGene })}
</SequenceViewWrapper>
)
Expand All @@ -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 (
<SequenceViewWrapper>
<SequenceViewWrapper onScroll={handleScroll} onWheel={handleWheel}>
<PeptideViewMissing geneName={gene.geneName} reasons={warningsForThisGene} />
</SequenceViewWrapper>
)
}

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)
Expand All @@ -134,8 +172,8 @@ export function PeptideViewUnsizedDisconnected({ width, sequence, warnings, gene
))

return (
<SequenceViewWrapper>
<SequenceViewSVG viewBox={`0 0 ${width} 10`}>
<SequenceViewWrapper onScroll={handleScroll} onWheel={handleWheel}>
<SequenceViewSVG width={width} height={30} viewBox={`${pixelPan} 0 ${zoomedWidth} 10`}>
<rect fill="transparent" x={0} y={-10} width={gene.length} height="30" />

{unknownAaRangesForGene &&
Expand Down
Loading