Skip to content

Commit

Permalink
Add scroll zoom, pan buttons, and better scroll boundaries to dotplot (
Browse files Browse the repository at this point in the history
…#3561)

* Add pan buttons on dotplot

* Add wheel mode

* Wheel zoom

* New zoom/pan options, with ctrl+click and drag performing toggleable action

* Min/max zoom levels on dotplot

* Much tighter 1d bounds restrictions to avoid scrolling offscreen
  • Loading branch information
cmdcolin authored Mar 4, 2023
1 parent cebc2d6 commit ffe454c
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 134 deletions.
25 changes: 17 additions & 8 deletions packages/core/util/Base1DViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,17 +196,26 @@ const Base1DView = types
/**
* #action
*/
zoomTo(newBpPerPx: number, offset = self.width / 2) {
const bpPerPx = newBpPerPx
if (bpPerPx === self.bpPerPx) {
return self.bpPerPx
}
zoomTo(bpPerPx: number, offset = self.width / 2) {
const newBpPerPx = clamp(
bpPerPx,
'minBpPerPx' in self ? (self.minBpPerPx as number) : 0,
'maxBpPerPx' in self ? (self.maxBpPerPx as number) : Infinity,
)

const oldBpPerPx = self.bpPerPx
self.bpPerPx = bpPerPx
if (Math.abs(oldBpPerPx - newBpPerPx) < 0.000001) {
return oldBpPerPx
}

self.bpPerPx = newBpPerPx

// tweak the offset so that the center of the view remains at the same coordinate
// tweak the offset so that the center of the view remains at the same
// coordinate
self.offsetPx = clamp(
Math.round(((self.offsetPx + offset) * oldBpPerPx) / bpPerPx - offset),
Math.round(
((self.offsetPx + offset) * oldBpPerPx) / newBpPerPx - offset,
),
self.minOffset,
self.maxOffset,
)
Expand Down
21 changes: 21 additions & 0 deletions plugins/dotplot-view/src/DotplotView/1dview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@ const Dotplot1DView = Base1DView.extend(self => {
get maxBpPerPx() {
return self.totalBp / (self.width - 50)
},

/**
* #getter
*/
get minBpPerPx() {
return 1 / 50
},

/**
* #getter
*/
get maxOffset() {
return self.displayedRegionsTotalPx - self.width * 0.95
},

/**
* #getter
*/
get minOffset() {
return -self.width * 0.05
},
},
actions: {
/**
Expand Down
74 changes: 49 additions & 25 deletions plugins/dotplot-view/src/DotplotView/components/DotplotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,23 @@ const DotplotViewInternal = observer(function ({
const distanceX = useRef(0)
const distanceY = useRef(0)
const scheduled = useRef(false)
const [ctrlKeyWasUsed, setCtrlKeyWasUsed] = useState(false)
const svg = ref.current?.getBoundingClientRect() || blank
const mousedown = getOffset(mousedownClient, svg)
const mousecurr = getOffset(mousecurrClient, svg)
const mouseup = getOffset(mouseupClient, svg)
const mouserect = mouseup || mousecurr
const xdistance = mousedown && mouserect ? mouserect[0] - mousedown[0] : 0
const ydistance = mousedown && mouserect ? mouserect[1] - mousedown[1] : 0
const { hview, vview } = model
const { hview, vview, wheelMode } = model

const validPan =
(cursorMode === 'move' && !ctrlKeyWasUsed) ||
(cursorMode === 'crosshair' && ctrlKeyWasUsed)

const validSelect =
(cursorMode === 'move' && ctrlKeyWasUsed) ||
(cursorMode === 'crosshair' && !ctrlKeyWasUsed)

// use non-React wheel handler to properly prevent body scrolling
useEffect(() => {
Expand All @@ -117,8 +126,14 @@ const DotplotViewInternal = observer(function ({

window.requestAnimationFrame(() => {
transaction(() => {
hview.scroll(distanceX.current)
vview.scroll(distanceY.current)
if (wheelMode === 'pan') {
hview.scroll(distanceX.current / 3)
vview.scroll(distanceY.current / 10)
} else if (wheelMode === 'zoom') {
const val = distanceY.current < 0 ? 1.1 : 0.9
hview.zoomTo(hview.bpPerPx * val)
vview.zoomTo(vview.bpPerPx * val)
}
})
scheduled.current = false
distanceX.current = 0
Expand All @@ -132,33 +147,37 @@ const DotplotViewInternal = observer(function ({
return () => curr.removeEventListener('wheel', onWheel)
}
return () => {}
}, [hview, vview])
}, [hview, vview, wheelMode])

useEffect(() => {
function globalMouseMove(event: MouseEvent) {
setMouseCurrClient([event.clientX, event.clientY])

if (mousecurrClient && mousedownClient && cursorMode === 'move') {
if (mousecurrClient && mousedownClient && validPan) {
hview.scroll(-event.clientX + mousecurrClient[0])
vview.scroll(event.clientY - mousecurrClient[1])
}
}

window.addEventListener('mousemove', globalMouseMove)
return () => window.removeEventListener('mousemove', globalMouseMove)
}, [mousecurrClient, mousedownClient, cursorMode, hview, vview])
}, [
validPan,
mousecurrClient,
mousedownClient,
cursorMode,
ctrlKeyWasUsed,
hview,
vview,
])

// detect a mouseup after a mousedown was submitted, autoremoves mouseup
// once that single mouseup is set
useEffect(() => {
let cleanup = () => {}

function globalMouseUp(event: MouseEvent) {
if (
Math.abs(xdistance) > 3 &&
Math.abs(ydistance) > 3 &&
cursorMode === 'crosshair'
) {
if (Math.abs(xdistance) > 3 && Math.abs(ydistance) > 3 && validSelect) {
setMouseUpClient([event.clientX, event.clientY])
} else {
setMouseDownClient(undefined)
Expand All @@ -170,14 +189,23 @@ const DotplotViewInternal = observer(function ({
cleanup = () => window.removeEventListener('mouseup', globalMouseUp, true)
}
return cleanup
}, [mousedown, mousecurr, mouseup, xdistance, ydistance, cursorMode])
}, [
validSelect,
mousedown,
mousecurr,
mouseup,
xdistance,
ydistance,
ctrlKeyWasUsed,
cursorMode,
])

return (
<div>
<Header
model={model}
selection={
cursorMode === 'move' || !(mousedown && mouserect)
!validSelect || !(mousedown && mouserect)
? undefined
: {
width: Math.abs(xdistance),
Expand All @@ -191,24 +219,19 @@ const DotplotViewInternal = observer(function ({
onMouseLeave={() => setMouseOvered(false)}
onMouseEnter={() => setMouseOvered(true)}
>
<div
className={classes.container}
style={{
transform: `scaleX(${hview.scaleFactor}) scaleY(${vview.scaleFactor})`,
}}
>
<div className={classes.container}>
<VerticalAxis model={model} />
<HorizontalAxis model={model} />
<div ref={ref} className={classes.content}>
{mouseOvered && cursorMode === 'crosshair' ? (
{mouseOvered && validSelect ? (
<TooltipWhereMouseovered
model={model}
mouserect={mouserect}
xdistance={xdistance}
ydistance={ydistance}
/>
) : null}
{cursorMode === 'crosshair' ? (
{validSelect ? (
<TooltipWhereClicked
model={model}
mousedown={mousedown}
Expand All @@ -217,17 +240,18 @@ const DotplotViewInternal = observer(function ({
/>
) : null}
<div
style={{ cursor: cursorMode }}
style={{ cursor: ctrlKeyWasUsed ? 'pointer' : cursorMode }}
onMouseDown={event => {
if (event.button === 0) {
const { clientX, clientY } = event
setMouseDownClient([clientX, clientY])
setMouseCurrClient([clientX, clientY])
setCtrlKeyWasUsed(event.ctrlKey)
}
}}
>
<Grid model={model}>
{cursorMode === 'crosshair' && mousedown && mouserect ? (
{validSelect && mousedown && mouserect ? (
<rect
fill="rgba(255,0,0,0.3)"
x={Math.min(mouserect[0], mousedown[0])}
Expand Down Expand Up @@ -256,8 +280,8 @@ const DotplotViewInternal = observer(function ({
anchorPosition={
mouseupClient
? {
top: mouseupClient[1] + 30,
left: mouseupClient[0] + 30,
top: mouseupClient[1] + 50,
left: mouseupClient[0] + 50,
}
: undefined
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { lazy, useState } from 'react'
import { Alert, Button } from '@mui/material'
import { observer } from 'mobx-react'

// locals
import { DotplotViewModel } from '../model'

// lazy components
const WarningDialog = lazy(() => import('./WarningDialog'))

export default observer(function ({ model }: { model: DotplotViewModel }) {
const trackWarnings = model.tracks.filter(t => t.displays[0].warnings?.length)
const [shown, setShown] = useState(false)
return trackWarnings.length ? (
<Alert severity="warning">
Warnings during render{' '}
<Button onClick={() => setShown(true)}>More info</Button>
{shown ? (
<WarningDialog
trackWarnings={trackWarnings}
handleClose={() => setShown(false)}
/>
) : null}
</Alert>
) : null
})
Loading

0 comments on commit ffe454c

Please sign in to comment.