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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jbrowse-plugin-graphgenomeviewer",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.23",
|
|
4
4
|
"description": "JBrowse 2 graph genome (pangenome) view: rGFA reference-anchored layouts, the Bandage force-directed engine, and haplotype lanes read from a gbz-base database",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"typescript-eslint": "^8.70.1",
|
|
77
77
|
"typescript7": "npm:typescript@^7.0.2",
|
|
78
78
|
"vitest": "^5.0.2",
|
|
79
|
-
"@jbrowse/bandage-core": "0.1.
|
|
79
|
+
"@jbrowse/bandage-core": "0.1.19"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
82
|
"react": ">=18.0.0",
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
import { HALO_FACTOR } from '@jbrowse/bandage-core/labelLayout'
|
|
7
7
|
import { observer } from 'mobx-react'
|
|
8
8
|
|
|
9
|
+
import { legendBoxStyle, legendRowStyle } from './legendStyles'
|
|
10
|
+
|
|
9
11
|
import type { GraphPaneModel } from '../model'
|
|
10
12
|
import type { BubbleHalo } from '@jbrowse/bandage-core/bubbles/bubbleHalos'
|
|
11
13
|
|
|
@@ -83,16 +85,6 @@ const BubbleHalos = observer(function BubbleHalos({
|
|
|
83
85
|
|
|
84
86
|
export default BubbleHalos
|
|
85
87
|
|
|
86
|
-
const legendBoxStyle = {
|
|
87
|
-
background: 'rgba(255,255,255,0.82)',
|
|
88
|
-
padding: '4px 6px',
|
|
89
|
-
borderRadius: 3,
|
|
90
|
-
fontSize: 11,
|
|
91
|
-
lineHeight: '15px',
|
|
92
|
-
whiteSpace: 'nowrap' as const,
|
|
93
|
-
}
|
|
94
|
-
const legendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
|
|
95
|
-
|
|
96
88
|
function HaloSwatch({ color }: { color: string }) {
|
|
97
89
|
return (
|
|
98
90
|
<svg width={18} height={10} style={{ flex: 'none' }}>
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { FACET_GAP_PX, FACET_TITLE_PX } from '@jbrowse/bandage-core/facetGrid'
|
|
4
|
+
import { Canvas2DRenderer } from '@jbrowse/bandage-core/renderer/Canvas2DRenderer'
|
|
5
|
+
import { autorun, computed } from 'mobx'
|
|
6
|
+
import { observer } from 'mobx-react'
|
|
7
|
+
|
|
8
|
+
import WalkKey from './WalkKey'
|
|
9
|
+
import { useWheelZoom } from './usePaneGestures'
|
|
10
|
+
|
|
11
|
+
import type { PaneHandlers } from './usePaneGestures'
|
|
12
|
+
import type { GraphPaneModel } from '../model'
|
|
13
|
+
import type { WalkLift } from '@jbrowse/bandage-core/walkHighlight'
|
|
14
|
+
|
|
15
|
+
// The pane faceted by walk: the same layout drawn once per lifted walk, each
|
|
16
|
+
// panel with that walk alone shading along itself, so walks compare by where
|
|
17
|
+
// they go rather than by which lane is which colour. The panels share the
|
|
18
|
+
// pane's transform, so a pan, zoom or hover in one is in all of them.
|
|
19
|
+
|
|
20
|
+
const gridStyle = {
|
|
21
|
+
position: 'absolute' as const,
|
|
22
|
+
left: 0,
|
|
23
|
+
top: 0,
|
|
24
|
+
zIndex: 4,
|
|
25
|
+
display: 'grid',
|
|
26
|
+
gap: FACET_GAP_PX,
|
|
27
|
+
lineHeight: 'normal',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const titleStyle = {
|
|
31
|
+
height: FACET_TITLE_PX,
|
|
32
|
+
padding: '2px 6px',
|
|
33
|
+
boxSizing: 'border-box' as const,
|
|
34
|
+
fontFamily: 'inherit',
|
|
35
|
+
fontSize: 11,
|
|
36
|
+
color: 'inherit',
|
|
37
|
+
cursor: 'pointer',
|
|
38
|
+
border: 'none',
|
|
39
|
+
background: 'none',
|
|
40
|
+
textAlign: 'left' as const,
|
|
41
|
+
display: 'block',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const FacetPanel = observer(function FacetPanel({
|
|
45
|
+
model,
|
|
46
|
+
lift,
|
|
47
|
+
width,
|
|
48
|
+
height,
|
|
49
|
+
handlers,
|
|
50
|
+
}: {
|
|
51
|
+
model: GraphPaneModel
|
|
52
|
+
lift: WalkLift
|
|
53
|
+
width: number
|
|
54
|
+
height: number
|
|
55
|
+
handlers: PaneHandlers
|
|
56
|
+
}) {
|
|
57
|
+
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null)
|
|
58
|
+
const walk = lift.walks[0]!
|
|
59
|
+
useWheelZoom(canvas, model)
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!canvas) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
const renderer = new Canvas2DRenderer(canvas)
|
|
66
|
+
renderer.resize(width, height)
|
|
67
|
+
const drawing = computed(() => model.buildDrawing(lift, false))
|
|
68
|
+
let uploaded: ReturnType<typeof model.buildDrawing>
|
|
69
|
+
const dispose = autorun(() => {
|
|
70
|
+
const built = drawing.get()
|
|
71
|
+
if (!built) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (built !== uploaded) {
|
|
75
|
+
renderer.uploadGeometry(built.batch)
|
|
76
|
+
uploaded = built
|
|
77
|
+
}
|
|
78
|
+
model.applyHighlights(renderer)
|
|
79
|
+
model.paint(renderer)
|
|
80
|
+
})
|
|
81
|
+
return () => {
|
|
82
|
+
dispose()
|
|
83
|
+
renderer.dispose()
|
|
84
|
+
}
|
|
85
|
+
}, [canvas, model, lift, width, height])
|
|
86
|
+
|
|
87
|
+
const label = model.walkLabel(walk.name)
|
|
88
|
+
return (
|
|
89
|
+
<div>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
style={titleStyle}
|
|
93
|
+
data-testid="graph-facet-title"
|
|
94
|
+
onClick={() => {
|
|
95
|
+
model.liftWalks([walk.name])
|
|
96
|
+
model.setFacet('none')
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<WalkKey
|
|
100
|
+
walk={walk}
|
|
101
|
+
label={label}
|
|
102
|
+
reference={model.walkReference}
|
|
103
|
+
hint={`click to lift ${label} alone`}
|
|
104
|
+
/>
|
|
105
|
+
</button>
|
|
106
|
+
<canvas
|
|
107
|
+
ref={setCanvas}
|
|
108
|
+
data-testid="graph-facet-canvas"
|
|
109
|
+
style={{
|
|
110
|
+
width,
|
|
111
|
+
height,
|
|
112
|
+
display: 'block',
|
|
113
|
+
cursor: model.isPanning || model.draggingNode ? 'grabbing' : 'grab',
|
|
114
|
+
}}
|
|
115
|
+
{...handlers}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const FacetPanels = observer(function FacetPanels({
|
|
122
|
+
model,
|
|
123
|
+
handlers,
|
|
124
|
+
}: {
|
|
125
|
+
model: GraphPaneModel
|
|
126
|
+
handlers: PaneHandlers
|
|
127
|
+
}) {
|
|
128
|
+
const panels = model.facetPanels
|
|
129
|
+
const grid = model.facetGrid
|
|
130
|
+
if (!panels || !grid) {
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
const { columns, width, height } = grid
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
data-testid="graph-facet-panels"
|
|
137
|
+
style={{
|
|
138
|
+
...gridStyle,
|
|
139
|
+
width: model.paneWidth,
|
|
140
|
+
minHeight: model.canvasHeight,
|
|
141
|
+
gridTemplateColumns: `repeat(${columns}, ${width}px)`,
|
|
142
|
+
background: model.darkMode ? '#1f1f1f' : 'white',
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{panels.map(lift => (
|
|
146
|
+
<FacetPanel
|
|
147
|
+
key={lift.walks[0]!.name}
|
|
148
|
+
model={model}
|
|
149
|
+
lift={lift}
|
|
150
|
+
width={width}
|
|
151
|
+
height={height}
|
|
152
|
+
handlers={handlers}
|
|
153
|
+
/>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export default FacetPanels
|
|
@@ -4,7 +4,8 @@ import type { GraphPaneModel } from '../model'
|
|
|
4
4
|
|
|
5
5
|
// The session's genes drawn onto the graph: exons as dark stretches along the
|
|
6
6
|
// backbone nodes that carry them, in layout units, moved by the same transform
|
|
7
|
-
// the halos use. Their names are LabelLayer's.
|
|
7
|
+
// the halos use. Their names are LabelLayer's. Over lifted walks an exon is a
|
|
8
|
+
// faint band across all the lanes, so no lane reads as broken by it.
|
|
8
9
|
|
|
9
10
|
const svgStyle = {
|
|
10
11
|
position: 'absolute' as const,
|
|
@@ -16,13 +17,15 @@ const svgStyle = {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
export const EXON_COLOR = '#1c1c22'
|
|
20
|
+
// a lane's width at the least, for the band an exon draws across them
|
|
21
|
+
const EXON_BAND_LANE_PX = 4
|
|
19
22
|
|
|
20
23
|
const GenePins = observer(function GenePins({
|
|
21
24
|
model,
|
|
22
25
|
}: {
|
|
23
26
|
model: GraphPaneModel
|
|
24
27
|
}) {
|
|
25
|
-
const { genePins } = model
|
|
28
|
+
const { genePins, walkLift } = model
|
|
26
29
|
if (genePins.length === 0) {
|
|
27
30
|
return null
|
|
28
31
|
}
|
|
@@ -52,9 +55,13 @@ const GenePins = observer(function GenePins({
|
|
|
52
55
|
d={pin.exons}
|
|
53
56
|
fill="none"
|
|
54
57
|
stroke={EXON_COLOR}
|
|
55
|
-
strokeOpacity={0.9}
|
|
56
|
-
strokeWidth={
|
|
57
|
-
|
|
58
|
+
strokeOpacity={walkLift ? 0.2 : 0.9}
|
|
59
|
+
strokeWidth={
|
|
60
|
+
walkLift
|
|
61
|
+
? walkLift.walks.length * EXON_BAND_LANE_PX + 6
|
|
62
|
+
: contigThickness * 0.55
|
|
63
|
+
}
|
|
64
|
+
strokeLinecap={walkLift ? 'butt' : 'round'}
|
|
58
65
|
vectorEffect="non-scaling-stroke"
|
|
59
66
|
/>
|
|
60
67
|
) : null,
|
|
@@ -1,17 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
2
|
|
|
3
3
|
import { formatBp } from '@jbrowse/bandage-core/graphLabels'
|
|
4
4
|
import { LEGEND_INSET_PX } from '@jbrowse/bandage-core/labelLayout'
|
|
5
|
-
import {
|
|
6
|
-
findHoveredEdge,
|
|
7
|
-
findHoveredNode,
|
|
8
|
-
} from '@jbrowse/bandage-core/util/hitDetection'
|
|
9
|
-
import { wheelZoomFactor } from '@jbrowse/bandage-core/util/wheelZoom'
|
|
10
|
-
import {
|
|
11
|
-
WALK_FIELDS,
|
|
12
|
-
encodingSwatchCss,
|
|
13
|
-
fieldLegend,
|
|
14
|
-
} from '@jbrowse/bandage-core/walkEncoding'
|
|
15
5
|
import { ErrorBanner, LoadingOverlay, Menu } from '@jbrowse/core/ui'
|
|
16
6
|
import { isAlive } from '@jbrowse/mobx-state-tree'
|
|
17
7
|
import { useRenderingBackend } from '@jbrowse/render-core/useRenderingBackend'
|
|
@@ -19,6 +9,7 @@ import InfoIcon from '@mui/icons-material/Info'
|
|
|
19
9
|
import { observer } from 'mobx-react'
|
|
20
10
|
|
|
21
11
|
import BubbleHalos, { HaloLegend } from './BubbleHalos'
|
|
12
|
+
import FacetPanels from './FacetPanels'
|
|
22
13
|
import GenePins from './GenePins'
|
|
23
14
|
import GraphToolbar from './GraphToolbar'
|
|
24
15
|
import LabelLayer from './LabelLayer'
|
|
@@ -27,8 +18,11 @@ import ReferenceStripOverlay, {
|
|
|
27
18
|
} from './ReferenceStripOverlay'
|
|
28
19
|
import TubeMapOverlay, { TubeMapLegend } from './TubeMapOverlay'
|
|
29
20
|
import UnpopButton from './UnpopButton'
|
|
21
|
+
import WalkKey, { walkSwatchStyle } from './WalkKey'
|
|
30
22
|
import WalkRowsOverlay, { WalkRowsLegend } from './WalkRowsOverlay'
|
|
23
|
+
import { legendBoxStyle, legendRowStyle } from './legendStyles'
|
|
31
24
|
import { RAMP_GRADIENT_CSS } from './referenceRampCss'
|
|
25
|
+
import { usePaneGestures, useWheelZoom } from './usePaneGestures'
|
|
32
26
|
import { locLabel, nodeOwnLocation } from '../../launchFromGraph/contributors'
|
|
33
27
|
import { nodeLaunchMenuItems } from '../../launchFromGraph/graphMenuItems'
|
|
34
28
|
import { createGraphRenderer } from '../renderer/GraphRenderer'
|
|
@@ -49,6 +43,7 @@ const tooltipStyle = {
|
|
|
49
43
|
borderRadius: 4,
|
|
50
44
|
fontSize: 12,
|
|
51
45
|
pointerEvents: 'none' as const,
|
|
46
|
+
zIndex: 6,
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
const wrapperStyle = { position: 'relative' as const }
|
|
@@ -147,17 +142,6 @@ const legendStackStyle = {
|
|
|
147
142
|
zIndex: 4,
|
|
148
143
|
}
|
|
149
144
|
|
|
150
|
-
const legendBoxStyle = {
|
|
151
|
-
background: 'rgba(255,255,255,0.82)',
|
|
152
|
-
padding: '4px 6px',
|
|
153
|
-
borderRadius: 3,
|
|
154
|
-
fontSize: 11,
|
|
155
|
-
lineHeight: '15px',
|
|
156
|
-
whiteSpace: 'nowrap' as const,
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const pathLegendRowStyle = { display: 'flex', alignItems: 'center', gap: 5 }
|
|
160
|
-
|
|
161
145
|
const pathSwatchStyle = { width: 18, height: 3, borderRadius: 2 }
|
|
162
146
|
|
|
163
147
|
// Which haplotype each ribbon colour is. The ribbons are one stroke per path
|
|
@@ -172,7 +156,7 @@ const PathLegend = observer(function PathLegend({
|
|
|
172
156
|
return pathLegend.length > 0 ? (
|
|
173
157
|
<div style={legendBoxStyle} data-testid="graph-path-legend">
|
|
174
158
|
{pathLegend.map(({ name, label, color }) => (
|
|
175
|
-
<div key={name} style={
|
|
159
|
+
<div key={name} style={legendRowStyle}>
|
|
176
160
|
<div style={{ ...pathSwatchStyle, backgroundColor: color }} />
|
|
177
161
|
<span>{label}</span>
|
|
178
162
|
</div>
|
|
@@ -181,15 +165,10 @@ const PathLegend = observer(function PathLegend({
|
|
|
181
165
|
) : null
|
|
182
166
|
})
|
|
183
167
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// What each lifted walk carries through the window, against the reference
|
|
187
|
-
// walk where the graph has one, beside the scale its lane is coloured by, and
|
|
188
|
-
// what each field in use means
|
|
189
|
-
function fieldName(field: string) {
|
|
190
|
-
return WALK_FIELDS.find(f => f.value === field)!.label.toLowerCase()
|
|
191
|
-
}
|
|
168
|
+
// a faded node: grey at the fade's alpha
|
|
169
|
+
const FADED_SWATCH = 'rgba(160, 160, 160, 0.18)'
|
|
192
170
|
|
|
171
|
+
// Each lifted walk's key, then what the faded rest is
|
|
193
172
|
const WalkReadout = observer(function WalkReadout({
|
|
194
173
|
model,
|
|
195
174
|
}: {
|
|
@@ -199,42 +178,22 @@ const WalkReadout = observer(function WalkReadout({
|
|
|
199
178
|
if (!lift) {
|
|
200
179
|
return null
|
|
201
180
|
}
|
|
202
|
-
const
|
|
203
|
-
model.walkChoices.find(c => c.name === name)?.label ?? name
|
|
181
|
+
const alone = lift.walks.length === 1
|
|
204
182
|
return (
|
|
205
183
|
<div style={legendBoxStyle} data-testid="graph-walk-readout">
|
|
206
|
-
{lift.walks.map(w =>
|
|
207
|
-
|
|
208
|
-
w.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return (
|
|
214
|
-
<div key={w.name} style={pathLegendRowStyle}>
|
|
215
|
-
<div
|
|
216
|
-
style={{
|
|
217
|
-
...walkSwatchStyle,
|
|
218
|
-
background: encodingSwatchCss(w.encoding),
|
|
219
|
-
}}
|
|
220
|
-
/>
|
|
221
|
-
<span>
|
|
222
|
-
<strong>{labelOf(w.name)}</strong>: {w.bp.toLocaleString()} bp
|
|
223
|
-
{delta} · {fieldName(w.encoding.field)}
|
|
224
|
-
</span>
|
|
225
|
-
</div>
|
|
226
|
-
)
|
|
227
|
-
})}
|
|
228
|
-
{WALK_FIELDS.filter(f =>
|
|
229
|
-
lift.walks.some(w => w.encoding.field === f.value),
|
|
230
|
-
).map(f => (
|
|
231
|
-
<div key={f.value}>
|
|
232
|
-
{fieldName(f.value)}: {fieldLegend(f.value)}
|
|
233
|
-
</div>
|
|
184
|
+
{lift.walks.map(w => (
|
|
185
|
+
<WalkKey
|
|
186
|
+
key={w.name}
|
|
187
|
+
walk={w}
|
|
188
|
+
label={model.walkLabel(w.name)}
|
|
189
|
+
reference={model.walkReference}
|
|
190
|
+
/>
|
|
234
191
|
))}
|
|
235
|
-
<div>
|
|
236
|
-
|
|
237
|
-
|
|
192
|
+
<div style={legendRowStyle}>
|
|
193
|
+
<div style={{ ...walkSwatchStyle, background: FADED_SWATCH }} />
|
|
194
|
+
<span>
|
|
195
|
+
not on {alone ? model.walkLabel(lift.walks[0]!.name) : 'these walks'}
|
|
196
|
+
</span>
|
|
238
197
|
</div>
|
|
239
198
|
</div>
|
|
240
199
|
)
|
|
@@ -262,7 +221,8 @@ const ReferenceRampLegend = observer(function ReferenceRampLegend({
|
|
|
262
221
|
}: {
|
|
263
222
|
model: GraphPaneModel
|
|
264
223
|
}) {
|
|
265
|
-
|
|
224
|
+
// under lifted walks each lane states its own scale, and the rest is grey
|
|
225
|
+
const domain = model.walkLift ? undefined : model.referenceRampDomain
|
|
266
226
|
return domain ? (
|
|
267
227
|
<div style={legendBoxStyle} data-testid="graph-ramp-legend">
|
|
268
228
|
<div style={rampStripStyle} />
|
|
@@ -542,241 +502,9 @@ const GraphCanvas = observer(function GraphCanvas({
|
|
|
542
502
|
error: renderError,
|
|
543
503
|
retry: retryRender,
|
|
544
504
|
} = useRenderingBackend(createGraphRenderer, model)
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
// because the cursor renders from it.
|
|
549
|
-
const lastMouseRef = useRef({ x: 0, y: 0 })
|
|
550
|
-
const hasMovedRef = useRef(false)
|
|
551
|
-
// A pan and a hover are applied once per frame, not once per mousemove:
|
|
552
|
-
// mousemove fires in bursts well above the frame rate, and each pan step
|
|
553
|
-
// repainted the canvas and re-placed every overlay label, while each hover
|
|
554
|
-
// step ran both hit indexes. The pending pan is the summed delta; the pending
|
|
555
|
-
// hover is the last pointer position, since only the last one can be right.
|
|
556
|
-
const pendingRef = useRef<{
|
|
557
|
-
frame: number
|
|
558
|
-
pan: { dx: number; dy: number } | null
|
|
559
|
-
hover: { x: number; y: number } | null
|
|
560
|
-
}>({ frame: 0, pan: null, hover: null })
|
|
561
|
-
useEffect(
|
|
562
|
-
() => () => {
|
|
563
|
-
cancelAnimationFrame(pendingRef.current.frame)
|
|
564
|
-
},
|
|
565
|
-
[],
|
|
566
|
-
)
|
|
567
|
-
const [contextNode, setContextNode] = useState<
|
|
568
|
-
{ nodeId: string; top: number; left: number } | undefined
|
|
569
|
-
>(undefined)
|
|
570
|
-
|
|
571
|
-
// wheel events need passive:false to call preventDefault — React registers
|
|
572
|
-
// wheel listeners as passive, so we must add this imperatively
|
|
573
|
-
useEffect(() => {
|
|
574
|
-
if (canvas) {
|
|
575
|
-
const c = canvas
|
|
576
|
-
function handleWheel(e: WheelEvent) {
|
|
577
|
-
if (model.hostPlacesX) {
|
|
578
|
-
return
|
|
579
|
-
}
|
|
580
|
-
if (model.host) {
|
|
581
|
-
e.stopPropagation()
|
|
582
|
-
}
|
|
583
|
-
e.preventDefault()
|
|
584
|
-
const rect = c.getBoundingClientRect()
|
|
585
|
-
model.zoom(
|
|
586
|
-
wheelZoomFactor(e),
|
|
587
|
-
e.clientX - rect.left,
|
|
588
|
-
e.clientY - rect.top,
|
|
589
|
-
)
|
|
590
|
-
}
|
|
591
|
-
c.addEventListener('wheel', handleWheel, { passive: false })
|
|
592
|
-
return () => {
|
|
593
|
-
c.removeEventListener('wheel', handleWheel)
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
return undefined
|
|
597
|
-
}, [canvas, model])
|
|
598
|
-
|
|
599
|
-
function screenToGraph(screenX: number, screenY: number) {
|
|
600
|
-
return {
|
|
601
|
-
x: (screenX - model.translateX) / model.scaleX,
|
|
602
|
-
y: (screenY - model.translateY) / model.scaleY,
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function getMouseCoord(e: React.MouseEvent) {
|
|
607
|
-
const rect = (e.currentTarget as HTMLCanvasElement).getBoundingClientRect()
|
|
608
|
-
return screenToGraph(e.clientX - rect.left, e.clientY - rect.top)
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// The node at a graph coordinate, which mousedown, mousemove and click all
|
|
612
|
-
// need. Takes the coordinate rather than the event so a caller that already
|
|
613
|
-
// has one does not pay for a second getBoundingClientRect.
|
|
614
|
-
function nodeAt(x: number, y: number) {
|
|
615
|
-
const { nodePositions } = model
|
|
616
|
-
const sx = x * model.scaleX + model.translateX
|
|
617
|
-
const sy = y * model.scaleY + model.translateY
|
|
618
|
-
if (model.layoutResult?.tubeMap) {
|
|
619
|
-
return model.tubeMapNodeAt(sx, sy)
|
|
620
|
-
}
|
|
621
|
-
const onStrip = model.referenceStripNodeAt(sx, sy)
|
|
622
|
-
if (onStrip) {
|
|
623
|
-
return onStrip
|
|
624
|
-
}
|
|
625
|
-
return nodePositions
|
|
626
|
-
? findHoveredNode(
|
|
627
|
-
nodePositions,
|
|
628
|
-
x,
|
|
629
|
-
y,
|
|
630
|
-
model.axisScale,
|
|
631
|
-
model.positionsVersion,
|
|
632
|
-
model.nodeInk,
|
|
633
|
-
)
|
|
634
|
-
: null
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
function handleMouseDown(e: React.MouseEvent) {
|
|
638
|
-
if (e.button === 0) {
|
|
639
|
-
hasMovedRef.current = false
|
|
640
|
-
if (model.hostPlacesX) {
|
|
641
|
-
return
|
|
642
|
-
}
|
|
643
|
-
if (model.host) {
|
|
644
|
-
e.stopPropagation()
|
|
645
|
-
}
|
|
646
|
-
const { x, y } = getMouseCoord(e)
|
|
647
|
-
// a tube map's boxes are the layout's, and the strip's are bp, not
|
|
648
|
-
// positions to drag
|
|
649
|
-
const node =
|
|
650
|
-
model.layoutResult?.tubeMap ||
|
|
651
|
-
e.nativeEvent.offsetY < model.referenceStripZonePx
|
|
652
|
-
? null
|
|
653
|
-
: nodeAt(x, y)
|
|
654
|
-
if (node) {
|
|
655
|
-
model.setDraggingNode(node)
|
|
656
|
-
} else {
|
|
657
|
-
model.setPanning(true)
|
|
658
|
-
}
|
|
659
|
-
lastMouseRef.current = { x: e.clientX, y: e.clientY }
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function applyPending() {
|
|
664
|
-
const pending = pendingRef.current
|
|
665
|
-
pending.frame = 0
|
|
666
|
-
if (pending.pan) {
|
|
667
|
-
model.setTransform(
|
|
668
|
-
model.scale,
|
|
669
|
-
model.translateX + pending.pan.dx,
|
|
670
|
-
model.translateY + pending.pan.dy,
|
|
671
|
-
)
|
|
672
|
-
pending.pan = null
|
|
673
|
-
}
|
|
674
|
-
if (pending.hover && model.nodePositions && model.graph) {
|
|
675
|
-
const { x, y } = screenToGraph(pending.hover.x, pending.hover.y)
|
|
676
|
-
pending.hover = null
|
|
677
|
-
const node = nodeAt(x, y)
|
|
678
|
-
model.setHoveredNode(node)
|
|
679
|
-
model.setHoveredEdge(
|
|
680
|
-
node || model.layoutResult?.tubeMap
|
|
681
|
-
? null
|
|
682
|
-
: findHoveredEdge(
|
|
683
|
-
model.nodePositions,
|
|
684
|
-
model.graph,
|
|
685
|
-
x,
|
|
686
|
-
y,
|
|
687
|
-
model.axisScale,
|
|
688
|
-
// resolved, so the hit index bounds the ribbons that are
|
|
689
|
-
// actually drawn — see effectiveDrawPaths
|
|
690
|
-
model.effectiveDrawPaths,
|
|
691
|
-
model.positionsVersion,
|
|
692
|
-
model.deletionEdgeIndexes,
|
|
693
|
-
model.hiddenEdgeIndexes,
|
|
694
|
-
),
|
|
695
|
-
)
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function scheduleFrame() {
|
|
700
|
-
if (!pendingRef.current.frame) {
|
|
701
|
-
pendingRef.current.frame = requestAnimationFrame(applyPending)
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
function dropPending() {
|
|
706
|
-
const pending = pendingRef.current
|
|
707
|
-
cancelAnimationFrame(pending.frame)
|
|
708
|
-
pending.frame = 0
|
|
709
|
-
pending.pan = null
|
|
710
|
-
pending.hover = null
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function handleMouseMove(e: React.MouseEvent) {
|
|
714
|
-
const dx = e.clientX - lastMouseRef.current.x
|
|
715
|
-
const dy = e.clientY - lastMouseRef.current.y
|
|
716
|
-
lastMouseRef.current = { x: e.clientX, y: e.clientY }
|
|
717
|
-
|
|
718
|
-
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
|
|
719
|
-
hasMovedRef.current = true
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
const pending = pendingRef.current
|
|
723
|
-
if (model.draggingNode) {
|
|
724
|
-
model.moveNode(model.draggingNode, dx / model.scaleX, dy / model.scaleY)
|
|
725
|
-
} else if (model.isPanning) {
|
|
726
|
-
pending.pan = {
|
|
727
|
-
dx: (pending.pan?.dx ?? 0) + dx,
|
|
728
|
-
dy: (pending.pan?.dy ?? 0) + dy,
|
|
729
|
-
}
|
|
730
|
-
scheduleFrame()
|
|
731
|
-
} else {
|
|
732
|
-
const rect = (
|
|
733
|
-
e.currentTarget as HTMLCanvasElement
|
|
734
|
-
).getBoundingClientRect()
|
|
735
|
-
pending.hover = { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
|
736
|
-
scheduleFrame()
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function handleMouseUp() {
|
|
741
|
-
model.stopDragging()
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// also fired by the canvas unmounting under a resting pointer, after the
|
|
745
|
-
// view closing it has destroyed the model
|
|
746
|
-
function handleMouseLeave() {
|
|
747
|
-
dropPending()
|
|
748
|
-
if (!isAlive(model)) {
|
|
749
|
-
return
|
|
750
|
-
}
|
|
751
|
-
model.stopDragging()
|
|
752
|
-
model.setHoveredNode(null)
|
|
753
|
-
model.setHoveredEdge(null)
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// Right-clicking a node is the gesture that asks "where is this?", and until
|
|
757
|
-
// now the graph had no answer: a node named an assembly and an offset in its
|
|
758
|
-
// tags that nothing surfaced. The items come from the model's launch targets,
|
|
759
|
-
// so what is offered is what can actually be opened.
|
|
760
|
-
function handleContextMenu(e: React.MouseEvent) {
|
|
761
|
-
const { x, y } = getMouseCoord(e)
|
|
762
|
-
const node = nodeAt(x, y)
|
|
763
|
-
if (node) {
|
|
764
|
-
e.preventDefault()
|
|
765
|
-
setContextNode({ nodeId: node, top: e.clientY, left: e.clientX })
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function handleClick(e: React.MouseEvent) {
|
|
770
|
-
// a click that ended a drag selects nothing
|
|
771
|
-
if (!hasMovedRef.current) {
|
|
772
|
-
const { x, y } = getMouseCoord(e)
|
|
773
|
-
const node = nodeAt(x, y)
|
|
774
|
-
model.setSelectedNode(node)
|
|
775
|
-
if (node) {
|
|
776
|
-
model.showNodeDetails(node)
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
}
|
|
505
|
+
const { handlers, contextNode, closeContextMenu } = usePaneGestures(model)
|
|
506
|
+
useWheelZoom(canvas, model)
|
|
507
|
+
const faceted = model.facetPanels !== undefined
|
|
780
508
|
|
|
781
509
|
return (
|
|
782
510
|
<div style={wrapperStyle}>
|
|
@@ -802,24 +530,25 @@ const GraphCanvas = observer(function GraphCanvas({
|
|
|
802
530
|
cursor: model.isPanning || model.draggingNode ? 'grabbing' : 'grab',
|
|
803
531
|
display: 'block',
|
|
804
532
|
}}
|
|
805
|
-
|
|
806
|
-
onMouseMove={handleMouseMove}
|
|
807
|
-
onMouseUp={handleMouseUp}
|
|
808
|
-
onMouseLeave={handleMouseLeave}
|
|
809
|
-
onClick={handleClick}
|
|
810
|
-
onContextMenu={handleContextMenu}
|
|
533
|
+
{...handlers}
|
|
811
534
|
/>
|
|
812
535
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
536
|
+
{faceted ? (
|
|
537
|
+
<FacetPanels model={model} handlers={handlers} />
|
|
538
|
+
) : (
|
|
539
|
+
<>
|
|
540
|
+
<TubeMapOverlay model={model} />
|
|
541
|
+
<ReferenceStripOverlay model={model} />
|
|
542
|
+
<RowLabels model={model} />
|
|
543
|
+
<GraphSizeLabels model={model} />
|
|
544
|
+
<BubbleHalos model={model} />
|
|
545
|
+
<GenePins model={model} />
|
|
546
|
+
<LabelLayer model={model} />
|
|
547
|
+
<WalkRowsOverlay model={model} />
|
|
548
|
+
<Legends model={model} />
|
|
549
|
+
</>
|
|
550
|
+
)}
|
|
820
551
|
<UnpopButton model={model} />
|
|
821
|
-
<WalkRowsOverlay model={model} />
|
|
822
|
-
<Legends model={model} />
|
|
823
552
|
|
|
824
553
|
{ownChrome ? (
|
|
825
554
|
<div style={loadingLayerStyle}>
|
|
@@ -847,9 +576,7 @@ const GraphCanvas = observer(function GraphCanvas({
|
|
|
847
576
|
nodeId={contextNode.nodeId}
|
|
848
577
|
top={contextNode.top}
|
|
849
578
|
left={contextNode.left}
|
|
850
|
-
onClose={
|
|
851
|
-
setContextNode(undefined)
|
|
852
|
-
}}
|
|
579
|
+
onClose={closeContextMenu}
|
|
853
580
|
/>
|
|
854
581
|
) : null}
|
|
855
582
|
|