jbrowse-plugin-graphgenomeviewer 4.0.21 → 4.0.23
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.
- package/README.md +5 -2
- package/dist/chunks/{GraphGenomeView-CLUMXH4K.js → GraphGenomeView-QXKHSZQJ.js} +2 -2
- package/dist/chunks/{LinearGraphDisplay-4CMVCEM5.js → LinearGraphDisplay-GMMLJWQ6.js} +2 -2
- package/dist/chunks/chunk-75CO6QJZ.js +2 -0
- package/dist/chunks/chunk-75CO6QJZ.js.map +7 -0
- package/dist/chunks/chunk-NSXARD3R.js +3 -0
- package/dist/chunks/chunk-NSXARD3R.js.map +7 -0
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +3 -3
- package/package.json +2 -2
- package/src/GraphGenomeView/components/BubbleHalos.tsx +2 -10
- package/src/GraphGenomeView/components/FacetPanels.tsx +159 -0
- package/src/GraphGenomeView/components/GenePins.tsx +12 -5
- package/src/GraphGenomeView/components/GraphCanvas.tsx +45 -318
- package/src/GraphGenomeView/components/GraphToolbar.tsx +6 -2
- package/src/GraphGenomeView/components/ReferenceStripOverlay.tsx +21 -16
- package/src/GraphGenomeView/components/TubeMapOverlay.tsx +2 -9
- package/src/GraphGenomeView/components/UnpopButton.tsx +1 -2
- package/src/GraphGenomeView/components/WalkKey.tsx +52 -0
- package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +1 -9
- package/src/GraphGenomeView/components/WalkSelect.tsx +2 -2
- package/src/GraphGenomeView/components/legendStyles.ts +11 -0
- package/src/GraphGenomeView/components/usePaneGestures.ts +274 -0
- package/src/GraphGenomeView/model.test.ts +102 -10
- package/src/GraphGenomeView/model.ts +318 -119
- package/src/LinearGraphDisplay/model.ts +2 -0
- package/src/version.ts +1 -1
- package/dist/chunks/chunk-3FGJLRG2.js +0 -2
- package/dist/chunks/chunk-3FGJLRG2.js.map +0 -7
- package/dist/chunks/chunk-3SZJ4AFI.js +0 -3
- package/dist/chunks/chunk-3SZJ4AFI.js.map +0 -7
- /package/dist/chunks/{GraphGenomeView-CLUMXH4K.js.map → GraphGenomeView-QXKHSZQJ.js.map} +0 -0
- /package/dist/chunks/{LinearGraphDisplay-4CMVCEM5.js.map → LinearGraphDisplay-GMMLJWQ6.js.map} +0 -0
|
@@ -48,7 +48,7 @@ const GraphToolbar = observer(function GraphToolbar({
|
|
|
48
48
|
<IconButton
|
|
49
49
|
size="small"
|
|
50
50
|
onClick={() => {
|
|
51
|
-
model.zoom(1.5, model.
|
|
51
|
+
model.zoom(1.5, model.viewBox.width / 2, model.viewBox.height / 2)
|
|
52
52
|
}}
|
|
53
53
|
>
|
|
54
54
|
<ZoomInIcon />
|
|
@@ -58,7 +58,11 @@ const GraphToolbar = observer(function GraphToolbar({
|
|
|
58
58
|
<IconButton
|
|
59
59
|
size="small"
|
|
60
60
|
onClick={() => {
|
|
61
|
-
model.zoom(
|
|
61
|
+
model.zoom(
|
|
62
|
+
1 / 1.5,
|
|
63
|
+
model.viewBox.width / 2,
|
|
64
|
+
model.viewBox.height / 2,
|
|
65
|
+
)
|
|
62
66
|
}}
|
|
63
67
|
>
|
|
64
68
|
<ZoomOutIcon />
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react'
|
|
2
2
|
|
|
3
3
|
import { drawReferenceStrip } from '@jbrowse/bandage-core/referenceStrip'
|
|
4
|
+
import { encodingSwatchCss } from '@jbrowse/bandage-core/walkEncoding'
|
|
4
5
|
import { getDpr } from '@jbrowse/render-core/canvas2dUtils'
|
|
5
6
|
import { autorun } from 'mobx'
|
|
6
7
|
import { observer } from 'mobx-react'
|
|
7
8
|
|
|
9
|
+
import { legendBoxStyle, legendRowStyle } from './legendStyles'
|
|
10
|
+
|
|
8
11
|
import type { GraphPaneModel } from '../model'
|
|
9
12
|
|
|
10
13
|
const canvasStyle = {
|
|
@@ -64,27 +67,29 @@ const ReferenceStripOverlay = observer(function ReferenceStripOverlay({
|
|
|
64
67
|
|
|
65
68
|
export default ReferenceStripOverlay
|
|
66
69
|
|
|
67
|
-
const legendBoxStyle = {
|
|
68
|
-
background: 'rgba(255,255,255,0.82)',
|
|
69
|
-
padding: '4px 6px',
|
|
70
|
-
borderRadius: 3,
|
|
71
|
-
fontSize: 11,
|
|
72
|
-
lineHeight: '15px',
|
|
73
|
-
whiteSpace: 'nowrap' as const,
|
|
74
|
-
}
|
|
75
|
-
const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
|
|
76
70
|
const SWATCH_PX = 18
|
|
77
71
|
|
|
78
|
-
|
|
72
|
+
// the strip's own colours: the reference lane's under lifted walks, grey
|
|
73
|
+
// when the reference is not one of them, else the node scheme's ramp
|
|
74
|
+
function StripSwatch({ background }: { background: string }) {
|
|
79
75
|
return (
|
|
80
|
-
<
|
|
81
|
-
<rect x={0} y={0} width={6} height={4} fill="hsl(0, 70%, 50%)" />
|
|
82
|
-
<rect x={6} y={0} width={5} height={4} fill="hsl(120, 70%, 50%)" />
|
|
83
|
-
<rect x={11} y={0} width={7} height={4} fill="hsl(240, 70%, 50%)" />
|
|
84
|
-
</svg>
|
|
76
|
+
<div style={{ width: SWATCH_PX, height: 4, flex: 'none', background }} />
|
|
85
77
|
)
|
|
86
78
|
}
|
|
87
79
|
|
|
80
|
+
const RAMP_SWATCH =
|
|
81
|
+
'linear-gradient(to right, hsl(0, 70%, 50%) 0 33%, hsl(120, 70%, 50%) 33% 61%, hsl(240, 70%, 50%) 61%)'
|
|
82
|
+
|
|
83
|
+
function stripBackground(model: GraphPaneModel) {
|
|
84
|
+
const lift = model.walkLift
|
|
85
|
+
const reference = lift?.walks.find(w => w.reference)
|
|
86
|
+
return reference
|
|
87
|
+
? encodingSwatchCss(reference.encoding)
|
|
88
|
+
: lift
|
|
89
|
+
? 'rgb(160, 160, 160)'
|
|
90
|
+
: RAMP_SWATCH
|
|
91
|
+
}
|
|
92
|
+
|
|
88
93
|
function OverhangSwatch() {
|
|
89
94
|
return (
|
|
90
95
|
<svg width={SWATCH_PX} height={8} style={{ flex: 'none' }}>
|
|
@@ -102,7 +107,7 @@ export const ReferenceStripLegend = observer(function ReferenceStripLegend({
|
|
|
102
107
|
return model.referenceStripShown ? (
|
|
103
108
|
<div style={legendBoxStyle} data-testid="graph-reference-strip-legend">
|
|
104
109
|
<div style={legendRowStyle}>
|
|
105
|
-
<StripSwatch />
|
|
110
|
+
<StripSwatch background={stripBackground(model)} />
|
|
106
111
|
<span>top strip: reference segments at their bp</span>
|
|
107
112
|
</div>
|
|
108
113
|
{model.referenceStripOverhangs ? (
|
|
@@ -18,6 +18,8 @@ import { getDpr } from '@jbrowse/render-core/canvas2dUtils'
|
|
|
18
18
|
import { autorun } from 'mobx'
|
|
19
19
|
import { observer } from 'mobx-react'
|
|
20
20
|
|
|
21
|
+
import { legendBoxStyle, legendRowStyle } from './legendStyles'
|
|
22
|
+
|
|
21
23
|
import type { GraphPaneModel } from '../model'
|
|
22
24
|
|
|
23
25
|
const canvasStyle = {
|
|
@@ -109,15 +111,6 @@ const TubeMapOverlay = observer(function TubeMapOverlay({
|
|
|
109
111
|
|
|
110
112
|
export default TubeMapOverlay
|
|
111
113
|
|
|
112
|
-
const legendBoxStyle = {
|
|
113
|
-
background: 'rgba(255,255,255,0.82)',
|
|
114
|
-
padding: '4px 6px',
|
|
115
|
-
borderRadius: 3,
|
|
116
|
-
fontSize: 11,
|
|
117
|
-
lineHeight: '15px',
|
|
118
|
-
whiteSpace: 'nowrap' as const,
|
|
119
|
-
}
|
|
120
|
-
const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
|
|
121
114
|
const SWATCH_PX = 18
|
|
122
115
|
|
|
123
116
|
// Two boxes' brackets, as the ruler draws them under every box
|
|
@@ -6,7 +6,6 @@ import type { GraphPaneModel } from '../model'
|
|
|
6
6
|
const style = {
|
|
7
7
|
position: 'absolute' as const,
|
|
8
8
|
left: 8,
|
|
9
|
-
top: 8,
|
|
10
9
|
zIndex: 5,
|
|
11
10
|
background: 'rgba(255,255,255,0.9)',
|
|
12
11
|
textTransform: 'none' as const,
|
|
@@ -22,7 +21,7 @@ const UnpopButton = observer(function UnpopButton({
|
|
|
22
21
|
<Button
|
|
23
22
|
size="small"
|
|
24
23
|
variant="outlined"
|
|
25
|
-
style={style}
|
|
24
|
+
style={{ ...style, ...(model.facetPanels ? { bottom: 8 } : { top: 8 }) }}
|
|
26
25
|
data-testid="graph-unpop-bubble"
|
|
27
26
|
onClick={() => {
|
|
28
27
|
void model.unpopBubble()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { encodingSwatchCss } from '@jbrowse/bandage-core/walkEncoding'
|
|
2
|
+
import { walkKey } from '@jbrowse/bandage-core/walkKey'
|
|
3
|
+
|
|
4
|
+
import { legendRowStyle } from './legendStyles'
|
|
5
|
+
|
|
6
|
+
import type { LiftedWalk } from '@jbrowse/bandage-core/walkHighlight'
|
|
7
|
+
|
|
8
|
+
export const walkSwatchStyle = {
|
|
9
|
+
width: 18,
|
|
10
|
+
height: 8,
|
|
11
|
+
borderRadius: 2,
|
|
12
|
+
flex: 'none',
|
|
13
|
+
}
|
|
14
|
+
const walkBlockStyle = { marginBottom: 2 }
|
|
15
|
+
const walkBarStyle = { ...walkSwatchStyle, width: 48 }
|
|
16
|
+
|
|
17
|
+
// One walk's key: a swatch, or a short bar of the scale its lane shades by,
|
|
18
|
+
// then its name; see walkKey for the words
|
|
19
|
+
export default function WalkKey({
|
|
20
|
+
walk,
|
|
21
|
+
label,
|
|
22
|
+
reference,
|
|
23
|
+
hint,
|
|
24
|
+
}: {
|
|
25
|
+
walk: LiftedWalk
|
|
26
|
+
label: string
|
|
27
|
+
reference?: { name?: string; start: number; end: number }
|
|
28
|
+
hint?: string
|
|
29
|
+
}) {
|
|
30
|
+
const key = walkKey(walk, reference)
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
style={walkBlockStyle}
|
|
34
|
+
title={[key.hover, hint].filter(Boolean).join(' · ') || undefined}
|
|
35
|
+
>
|
|
36
|
+
<div style={legendRowStyle}>
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
...(key.shades ? walkBarStyle : walkSwatchStyle),
|
|
40
|
+
background: encodingSwatchCss(walk.encoding),
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
<span>
|
|
44
|
+
<strong>{label}</strong>
|
|
45
|
+
{key.delta}
|
|
46
|
+
{key.reversed}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
{key.scale ? <div>{key.scale}</div> : null}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -5,6 +5,7 @@ import { LABEL_CHAR_PX } from '@jbrowse/bandage-core/overlayLabels'
|
|
|
5
5
|
import { REFERENCE_RAMP_ALT_CSS } from '@jbrowse/bandage-core/renderer/GeometryBuilder'
|
|
6
6
|
import { observer } from 'mobx-react'
|
|
7
7
|
|
|
8
|
+
import { legendBoxStyle, legendRowStyle } from './legendStyles'
|
|
8
9
|
import { RAMP_GRADIENT_CSS, rampHueCss, rampStops } from './referenceRampCss'
|
|
9
10
|
import { CALL_TOLERANCE } from '../repeats/walkCalls'
|
|
10
11
|
|
|
@@ -44,15 +45,6 @@ const DISAGREES = '#c62828'
|
|
|
44
45
|
const OFF_REFERENCE = '#8e3fbf'
|
|
45
46
|
const BAR_PX = 12
|
|
46
47
|
|
|
47
|
-
const legendBoxStyle = {
|
|
48
|
-
background: 'rgba(255,255,255,0.82)',
|
|
49
|
-
padding: '4px 6px',
|
|
50
|
-
borderRadius: 3,
|
|
51
|
-
fontSize: 11,
|
|
52
|
-
lineHeight: '15px',
|
|
53
|
-
whiteSpace: 'nowrap' as const,
|
|
54
|
-
}
|
|
55
|
-
const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
|
|
56
48
|
const swatchStyle = { width: 18, height: BAR_PX - 4, borderRadius: 2 }
|
|
57
49
|
const tickSwatchStyle = {
|
|
58
50
|
...swatchStyle,
|
|
@@ -20,7 +20,7 @@ const useStyles = makeStyles()({
|
|
|
20
20
|
|
|
21
21
|
// Which walks to lift out of the drawing: Bandage's path highlight, for as many
|
|
22
22
|
// of the haplotypes a GBZ cut carries as the reader picks. Absent from a graph
|
|
23
|
-
// with no walks.
|
|
23
|
+
// with no walks, and from a tube map, whose tubes are the walks.
|
|
24
24
|
const WalkSelect = observer(function WalkSelect({
|
|
25
25
|
model,
|
|
26
26
|
}: {
|
|
@@ -28,7 +28,7 @@ const WalkSelect = observer(function WalkSelect({
|
|
|
28
28
|
}) {
|
|
29
29
|
const { classes } = useStyles()
|
|
30
30
|
const { walkChoices } = model
|
|
31
|
-
if (walkChoices.length === 0) {
|
|
31
|
+
if (walkChoices.length === 0 || !model.liftsWalks) {
|
|
32
32
|
return null
|
|
33
33
|
}
|
|
34
34
|
const names = new Set(walkChoices.map(c => c.name))
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A key's box over the drawing, and one row in it: a swatch beside its words
|
|
2
|
+
export const legendBoxStyle = {
|
|
3
|
+
background: 'rgba(255,255,255,0.82)',
|
|
4
|
+
padding: '4px 6px',
|
|
5
|
+
borderRadius: 3,
|
|
6
|
+
fontSize: 11,
|
|
7
|
+
lineHeight: '15px',
|
|
8
|
+
whiteSpace: 'nowrap' as const,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
findHoveredEdge,
|
|
5
|
+
findHoveredNode,
|
|
6
|
+
} from '@jbrowse/bandage-core/util/hitDetection'
|
|
7
|
+
import { wheelZoomFactor } from '@jbrowse/bandage-core/util/wheelZoom'
|
|
8
|
+
import { isAlive } from '@jbrowse/mobx-state-tree'
|
|
9
|
+
|
|
10
|
+
import type { GraphPaneModel } from '../model'
|
|
11
|
+
|
|
12
|
+
// wheel events need passive:false to call preventDefault — React registers
|
|
13
|
+
// wheel listeners as passive, so we must add this imperatively
|
|
14
|
+
export function useWheelZoom(
|
|
15
|
+
canvas: HTMLCanvasElement | null | undefined,
|
|
16
|
+
model: GraphPaneModel,
|
|
17
|
+
) {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!canvas) {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
const c = canvas
|
|
23
|
+
function handleWheel(e: WheelEvent) {
|
|
24
|
+
if (model.hostPlacesX) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
if (model.host) {
|
|
28
|
+
e.stopPropagation()
|
|
29
|
+
}
|
|
30
|
+
e.preventDefault()
|
|
31
|
+
const rect = c.getBoundingClientRect()
|
|
32
|
+
model.zoom(
|
|
33
|
+
wheelZoomFactor(e),
|
|
34
|
+
e.clientX - rect.left,
|
|
35
|
+
e.clientY - rect.top,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
c.addEventListener('wheel', handleWheel, { passive: false })
|
|
39
|
+
return () => {
|
|
40
|
+
c.removeEventListener('wheel', handleWheel)
|
|
41
|
+
}
|
|
42
|
+
}, [canvas, model])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The pointer on a canvas drawing the pane's transform: the pane's own, or any
|
|
46
|
+
// facet panel, since the panels share it. Coordinates are read against the
|
|
47
|
+
// canvas the event is on.
|
|
48
|
+
export function usePaneGestures(model: GraphPaneModel) {
|
|
49
|
+
// Where the pointer was last, and whether it has travelled since mousedown —
|
|
50
|
+
// per-gesture scratch that nothing renders from, which is what a ref is for.
|
|
51
|
+
// Whether a drag is in progress is model state (`isPanning`/`draggingNode`),
|
|
52
|
+
// because the cursor renders from it.
|
|
53
|
+
const lastMouseRef = useRef({ x: 0, y: 0 })
|
|
54
|
+
const hasMovedRef = useRef(false)
|
|
55
|
+
// A pan and a hover are applied once per frame, not once per mousemove:
|
|
56
|
+
// mousemove fires in bursts well above the frame rate, and each pan step
|
|
57
|
+
// repainted the canvas and re-placed every overlay label, while each hover
|
|
58
|
+
// step ran both hit indexes. The pending pan is the summed delta; the pending
|
|
59
|
+
// hover is the last pointer position, since only the last one can be right.
|
|
60
|
+
const pendingRef = useRef<{
|
|
61
|
+
frame: number
|
|
62
|
+
pan: { dx: number; dy: number } | null
|
|
63
|
+
hover: { x: number; y: number } | null
|
|
64
|
+
}>({ frame: 0, pan: null, hover: null })
|
|
65
|
+
useEffect(
|
|
66
|
+
() => () => {
|
|
67
|
+
cancelAnimationFrame(pendingRef.current.frame)
|
|
68
|
+
},
|
|
69
|
+
[],
|
|
70
|
+
)
|
|
71
|
+
const [contextNode, setContextNode] = useState<
|
|
72
|
+
{ nodeId: string; top: number; left: number } | undefined
|
|
73
|
+
>(undefined)
|
|
74
|
+
|
|
75
|
+
function screenToGraph(screenX: number, screenY: number) {
|
|
76
|
+
return {
|
|
77
|
+
x: (screenX - model.translateX) / model.scaleX,
|
|
78
|
+
y: (screenY - model.translateY) / model.scaleY,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getMouseCoord(e: React.MouseEvent) {
|
|
83
|
+
const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect()
|
|
84
|
+
return screenToGraph(e.clientX - rect.left, e.clientY - rect.top)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The node at a graph coordinate, which mousedown, mousemove and click all
|
|
88
|
+
// need. Takes the coordinate rather than the event so a caller that already
|
|
89
|
+
// has one does not pay for a second getBoundingClientRect.
|
|
90
|
+
function nodeAt(x: number, y: number) {
|
|
91
|
+
const { nodePositions } = model
|
|
92
|
+
const sx = x * model.scaleX + model.translateX
|
|
93
|
+
const sy = y * model.scaleY + model.translateY
|
|
94
|
+
if (model.layoutResult?.tubeMap) {
|
|
95
|
+
return model.tubeMapNodeAt(sx, sy)
|
|
96
|
+
}
|
|
97
|
+
const onStrip = model.referenceStripNodeAt(sx, sy)
|
|
98
|
+
if (onStrip) {
|
|
99
|
+
return onStrip
|
|
100
|
+
}
|
|
101
|
+
return nodePositions
|
|
102
|
+
? findHoveredNode(
|
|
103
|
+
nodePositions,
|
|
104
|
+
x,
|
|
105
|
+
y,
|
|
106
|
+
model.axisScale,
|
|
107
|
+
model.positionsVersion,
|
|
108
|
+
model.nodeInk,
|
|
109
|
+
)
|
|
110
|
+
: null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function onMouseDown(e: React.MouseEvent) {
|
|
114
|
+
if (e.button === 0) {
|
|
115
|
+
hasMovedRef.current = false
|
|
116
|
+
if (model.hostPlacesX) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (model.host) {
|
|
120
|
+
e.stopPropagation()
|
|
121
|
+
}
|
|
122
|
+
const { x, y } = getMouseCoord(e)
|
|
123
|
+
// a tube map's boxes are the layout's, and the strip's are bp, not
|
|
124
|
+
// positions to drag
|
|
125
|
+
const node =
|
|
126
|
+
model.layoutResult?.tubeMap ||
|
|
127
|
+
e.nativeEvent.offsetY < model.referenceStripZonePx
|
|
128
|
+
? null
|
|
129
|
+
: nodeAt(x, y)
|
|
130
|
+
if (node) {
|
|
131
|
+
model.setDraggingNode(node)
|
|
132
|
+
} else {
|
|
133
|
+
model.setPanning(true)
|
|
134
|
+
}
|
|
135
|
+
lastMouseRef.current = { x: e.clientX, y: e.clientY }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyPending() {
|
|
140
|
+
const pending = pendingRef.current
|
|
141
|
+
pending.frame = 0
|
|
142
|
+
if (pending.pan) {
|
|
143
|
+
model.setTransform(
|
|
144
|
+
model.scale,
|
|
145
|
+
model.translateX + pending.pan.dx,
|
|
146
|
+
model.translateY + pending.pan.dy,
|
|
147
|
+
)
|
|
148
|
+
pending.pan = null
|
|
149
|
+
}
|
|
150
|
+
if (pending.hover && model.nodePositions && model.graph) {
|
|
151
|
+
const { x, y } = screenToGraph(pending.hover.x, pending.hover.y)
|
|
152
|
+
pending.hover = null
|
|
153
|
+
const node = nodeAt(x, y)
|
|
154
|
+
model.setHoveredNode(node)
|
|
155
|
+
model.setHoveredEdge(
|
|
156
|
+
node || model.layoutResult?.tubeMap
|
|
157
|
+
? null
|
|
158
|
+
: findHoveredEdge(
|
|
159
|
+
model.nodePositions,
|
|
160
|
+
model.graph,
|
|
161
|
+
x,
|
|
162
|
+
y,
|
|
163
|
+
model.axisScale,
|
|
164
|
+
// resolved, so the hit index bounds the ribbons that are
|
|
165
|
+
// actually drawn — see effectiveDrawPaths. Facet panels draw
|
|
166
|
+
// none.
|
|
167
|
+
model.effectiveDrawPaths && !model.facetPanels,
|
|
168
|
+
model.positionsVersion,
|
|
169
|
+
model.deletionEdgeIndexes,
|
|
170
|
+
model.hiddenEdgeIndexes,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scheduleFrame() {
|
|
177
|
+
if (!pendingRef.current.frame) {
|
|
178
|
+
pendingRef.current.frame = requestAnimationFrame(applyPending)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dropPending() {
|
|
183
|
+
const pending = pendingRef.current
|
|
184
|
+
cancelAnimationFrame(pending.frame)
|
|
185
|
+
pending.frame = 0
|
|
186
|
+
pending.pan = null
|
|
187
|
+
pending.hover = null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function onMouseMove(e: React.MouseEvent) {
|
|
191
|
+
const dx = e.clientX - lastMouseRef.current.x
|
|
192
|
+
const dy = e.clientY - lastMouseRef.current.y
|
|
193
|
+
lastMouseRef.current = { x: e.clientX, y: e.clientY }
|
|
194
|
+
|
|
195
|
+
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
|
|
196
|
+
hasMovedRef.current = true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const pending = pendingRef.current
|
|
200
|
+
if (model.draggingNode) {
|
|
201
|
+
model.moveNode(model.draggingNode, dx / model.scaleX, dy / model.scaleY)
|
|
202
|
+
} else if (model.isPanning) {
|
|
203
|
+
pending.pan = {
|
|
204
|
+
dx: (pending.pan?.dx ?? 0) + dx,
|
|
205
|
+
dy: (pending.pan?.dy ?? 0) + dy,
|
|
206
|
+
}
|
|
207
|
+
scheduleFrame()
|
|
208
|
+
} else {
|
|
209
|
+
const rect = (
|
|
210
|
+
e.currentTarget as HTMLCanvasElement
|
|
211
|
+
).getBoundingClientRect()
|
|
212
|
+
pending.hover = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
213
|
+
scheduleFrame()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onMouseUp() {
|
|
218
|
+
model.stopDragging()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// also fired by the canvas unmounting under a resting pointer, after the
|
|
222
|
+
// view closing it has destroyed the model
|
|
223
|
+
function onMouseLeave() {
|
|
224
|
+
dropPending()
|
|
225
|
+
if (!isAlive(model)) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
model.stopDragging()
|
|
229
|
+
model.setHoveredNode(null)
|
|
230
|
+
model.setHoveredEdge(null)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Right-clicking a node is the gesture that asks "where is this?", and until
|
|
234
|
+
// now the graph had no answer: a node named an assembly and an offset in its
|
|
235
|
+
// tags that nothing surfaced. The items come from the model's launch targets,
|
|
236
|
+
// so what is offered is what can actually be opened.
|
|
237
|
+
function onContextMenu(e: React.MouseEvent) {
|
|
238
|
+
const { x, y } = getMouseCoord(e)
|
|
239
|
+
const node = nodeAt(x, y)
|
|
240
|
+
if (node) {
|
|
241
|
+
e.preventDefault()
|
|
242
|
+
setContextNode({ nodeId: node, top: e.clientY, left: e.clientX })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function onClick(e: React.MouseEvent) {
|
|
247
|
+
// a click that ended a drag selects nothing
|
|
248
|
+
if (!hasMovedRef.current) {
|
|
249
|
+
const { x, y } = getMouseCoord(e)
|
|
250
|
+
const node = nodeAt(x, y)
|
|
251
|
+
model.setSelectedNode(node)
|
|
252
|
+
if (node) {
|
|
253
|
+
model.showNodeDetails(node)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
handlers: {
|
|
260
|
+
onMouseDown,
|
|
261
|
+
onMouseMove,
|
|
262
|
+
onMouseUp,
|
|
263
|
+
onMouseLeave,
|
|
264
|
+
onClick,
|
|
265
|
+
onContextMenu,
|
|
266
|
+
},
|
|
267
|
+
contextNode,
|
|
268
|
+
closeContextMenu: () => {
|
|
269
|
+
setContextNode(undefined)
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export type PaneHandlers = ReturnType<typeof usePaneGestures>['handlers']
|
|
@@ -2494,17 +2494,17 @@ describe('walk rows', () => {
|
|
|
2494
2494
|
})
|
|
2495
2495
|
await model.loadGFA(WALKS_GFA, 'walks')
|
|
2496
2496
|
model.liftWalks(['B#1#ctg', 'GRCh38#0#chr1'])
|
|
2497
|
-
model.setWalkColor('B#1#ctg', { field: '
|
|
2498
|
-
model.setWalkColor('B#1#ctg', { scheme: '
|
|
2497
|
+
model.setWalkColor('B#1#ctg', { field: 'walk' })
|
|
2498
|
+
model.setWalkColor('B#1#ctg', { scheme: 'green' })
|
|
2499
2499
|
model.toggleWalk('A#1#ctg')
|
|
2500
2500
|
expect(model.walkLift!.walks.map(w => [w.name, w.encoding])).toEqual([
|
|
2501
|
-
['GRCh38#0#chr1', { field: '
|
|
2502
|
-
['B#1#ctg', { field: '
|
|
2503
|
-
['A#1#ctg', { field: '
|
|
2501
|
+
['GRCh38#0#chr1', { field: 'walk', scheme: 'grey' }],
|
|
2502
|
+
['B#1#ctg', { field: 'walk', scheme: 'green' }],
|
|
2503
|
+
['A#1#ctg', { field: 'walk', scheme: 'red' }],
|
|
2504
2504
|
])
|
|
2505
|
-
//
|
|
2505
|
+
// lifted together, each walk is one flat colour
|
|
2506
2506
|
const ref = model.walkLift!.walks[0]!
|
|
2507
|
-
expect(new Set(ref.colors.values()).size).toBe(
|
|
2507
|
+
expect(new Set(ref.colors.values()).size).toBe(1)
|
|
2508
2508
|
interface Item {
|
|
2509
2509
|
label?: string
|
|
2510
2510
|
checked?: boolean
|
|
@@ -2515,16 +2515,108 @@ describe('walk rows', () => {
|
|
|
2515
2515
|
)!
|
|
2516
2516
|
const colour = walkMenu.subMenu!.find(item => item.label === 'Colour B#1')!
|
|
2517
2517
|
expect(
|
|
2518
|
-
colour.subMenu
|
|
2519
|
-
).toEqual(['
|
|
2518
|
+
colour.subMenu!.filter(item => item.checked).map(item => item.label),
|
|
2519
|
+
).toEqual(['One colour for the walk', 'Lime to forest'])
|
|
2520
|
+
// the rainbow is offered for reference position only
|
|
2521
|
+
expect(colour.subMenu!.some(item => item.label === 'Rainbow')).toBe(false)
|
|
2520
2522
|
model.liftWalks(['B#1#ctg'])
|
|
2521
2523
|
expect(model.walkLayers).toEqual([
|
|
2522
|
-
{ walk: 'B#1#ctg', color: { field: '
|
|
2524
|
+
{ walk: 'B#1#ctg', color: { field: 'walk', scheme: 'green' } },
|
|
2523
2525
|
])
|
|
2524
2526
|
model.toggleWalk('B#1#ctg')
|
|
2525
2527
|
expect(model.walkLift).toBeUndefined()
|
|
2526
2528
|
})
|
|
2527
2529
|
|
|
2530
|
+
test('faceted by walk, the pane draws one panel per lifted walk on one scale', async () => {
|
|
2531
|
+
rpcRespond()
|
|
2532
|
+
const model = stateModelFactory().create({
|
|
2533
|
+
type: 'GraphGenomeView',
|
|
2534
|
+
layoutMode: 'auto',
|
|
2535
|
+
})
|
|
2536
|
+
await model.loadGFA(WALKS_GFA, 'walks')
|
|
2537
|
+
model.liftWalks(['GRCh38#0#chr1'])
|
|
2538
|
+
model.setFacet('walk')
|
|
2539
|
+
// one walk needs no facet
|
|
2540
|
+
expect(model.facetPanels).toBeUndefined()
|
|
2541
|
+
model.liftWalks(['GRCh38#0#chr1', 'B#1#ctg', 'A#1#ctg'])
|
|
2542
|
+
expect(
|
|
2543
|
+
model.facetPanels!.map(p => [p.walks[0]!.name, p.walks[0]!.encoding]),
|
|
2544
|
+
).toEqual([
|
|
2545
|
+
['GRCh38#0#chr1', { field: 'progress', scheme: 'red' }],
|
|
2546
|
+
['B#1#ctg', { field: 'progress', scheme: 'red' }],
|
|
2547
|
+
['A#1#ctg', { field: 'progress', scheme: 'red' }],
|
|
2548
|
+
])
|
|
2549
|
+
// a row layout's x is bp, so its panels stack full width
|
|
2550
|
+
expect(model.facetGrid!.columns).toBe(1)
|
|
2551
|
+
model.setFacetColumns(2)
|
|
2552
|
+
expect(model.facetGrid!.columns).toBe(2)
|
|
2553
|
+
model.setFacet('none')
|
|
2554
|
+
expect(model.facetPanels).toBeUndefined()
|
|
2555
|
+
})
|
|
2556
|
+
|
|
2557
|
+
test("faceted, the transform is each panel's and the menu keys what the panels draw", async () => {
|
|
2558
|
+
rpcRespond()
|
|
2559
|
+
const model = stateModelFactory().create({
|
|
2560
|
+
type: 'GraphGenomeView',
|
|
2561
|
+
layoutMode: 'auto',
|
|
2562
|
+
})
|
|
2563
|
+
await model.loadGFA(WALKS_GFA, 'walks')
|
|
2564
|
+
model.startRenderingBackend(fakeRenderer())
|
|
2565
|
+
model.liftWalks(['GRCh38#0#chr1', 'B#1#ctg'])
|
|
2566
|
+
const whole = model.scale
|
|
2567
|
+
model.setFacet('walk')
|
|
2568
|
+
const grid = model.facetGrid!
|
|
2569
|
+
expect(model.viewBox).toEqual({ width: grid.width, height: grid.height })
|
|
2570
|
+
expect(model.canvasHeight).toBe(Math.max(160, grid.total))
|
|
2571
|
+
expect(model.scale).toBeCloseTo(grid.scale, 10)
|
|
2572
|
+
expect(model.scale).not.toBeCloseTo(whole, 10)
|
|
2573
|
+
interface Item {
|
|
2574
|
+
label?: string
|
|
2575
|
+
checked?: boolean
|
|
2576
|
+
subMenu?: Item[]
|
|
2577
|
+
}
|
|
2578
|
+
const walkMenu = (model.graphMenuItems() as Item[]).find(
|
|
2579
|
+
item => item.label === 'Walk',
|
|
2580
|
+
)!
|
|
2581
|
+
const colour = walkMenu.subMenu!.find(item => item.label === 'Colour B#1')!
|
|
2582
|
+
expect(
|
|
2583
|
+
colour.subMenu!.filter(item => item.checked).map(item => item.label),
|
|
2584
|
+
).toEqual(['Progress along the walk', 'Yellow to red'])
|
|
2585
|
+
const columns = walkMenu.subMenu!.find(item => item.label === 'Columns')!
|
|
2586
|
+
expect(columns.subMenu!.map(item => item.label)).toEqual(['Auto', '1', '2'])
|
|
2587
|
+
})
|
|
2588
|
+
|
|
2589
|
+
test('a tube map lifts no walk: its tubes are the walks', async () => {
|
|
2590
|
+
rpcRespond()
|
|
2591
|
+
const model = stateModelFactory().create({
|
|
2592
|
+
type: 'GraphGenomeView',
|
|
2593
|
+
layoutMode: 'tubemap',
|
|
2594
|
+
walkLayers: [{ walk: 'GRCh38#0#chr1' }, { walk: 'B#1#ctg' }],
|
|
2595
|
+
facet: 'walk',
|
|
2596
|
+
})
|
|
2597
|
+
await model.loadGFA(WALKS_GFA, 'walks')
|
|
2598
|
+
expect(model.walkLift).toBeUndefined()
|
|
2599
|
+
expect(model.facetPanels).toBeUndefined()
|
|
2600
|
+
expect(
|
|
2601
|
+
(model.graphMenuItems() as { label?: string }[]).some(
|
|
2602
|
+
item => item.label === 'Walk',
|
|
2603
|
+
),
|
|
2604
|
+
).toBe(false)
|
|
2605
|
+
})
|
|
2606
|
+
|
|
2607
|
+
test('a lane reads the reference ramp only when it is coloured by it', async () => {
|
|
2608
|
+
rpcRespond()
|
|
2609
|
+
const model = stateModelFactory().create({
|
|
2610
|
+
type: 'GraphGenomeView',
|
|
2611
|
+
layoutMode: 'auto',
|
|
2612
|
+
})
|
|
2613
|
+
await model.loadGFA(WALKS_GFA, 'walks')
|
|
2614
|
+
model.liftWalks(['B#1#ctg'])
|
|
2615
|
+
expect(model.walkRamp).toBeUndefined()
|
|
2616
|
+
model.setWalkColor('B#1#ctg', { field: 'reference' })
|
|
2617
|
+
expect(model.walkLift!.referenceDomain).toBeDefined()
|
|
2618
|
+
})
|
|
2619
|
+
|
|
2528
2620
|
test('a session naming the one walk the old field lifted opens with none', () => {
|
|
2529
2621
|
const model = stateModelFactory().create({
|
|
2530
2622
|
type: 'GraphGenomeView',
|