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.
Files changed (33) hide show
  1. package/README.md +5 -2
  2. package/dist/chunks/{GraphGenomeView-CLUMXH4K.js → GraphGenomeView-QXKHSZQJ.js} +2 -2
  3. package/dist/chunks/{LinearGraphDisplay-4CMVCEM5.js → LinearGraphDisplay-GMMLJWQ6.js} +2 -2
  4. package/dist/chunks/chunk-75CO6QJZ.js +2 -0
  5. package/dist/chunks/chunk-75CO6QJZ.js.map +7 -0
  6. package/dist/chunks/chunk-NSXARD3R.js +3 -0
  7. package/dist/chunks/chunk-NSXARD3R.js.map +7 -0
  8. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  9. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +3 -3
  10. package/package.json +2 -2
  11. package/src/GraphGenomeView/components/BubbleHalos.tsx +2 -10
  12. package/src/GraphGenomeView/components/FacetPanels.tsx +159 -0
  13. package/src/GraphGenomeView/components/GenePins.tsx +12 -5
  14. package/src/GraphGenomeView/components/GraphCanvas.tsx +45 -318
  15. package/src/GraphGenomeView/components/GraphToolbar.tsx +6 -2
  16. package/src/GraphGenomeView/components/ReferenceStripOverlay.tsx +21 -16
  17. package/src/GraphGenomeView/components/TubeMapOverlay.tsx +2 -9
  18. package/src/GraphGenomeView/components/UnpopButton.tsx +1 -2
  19. package/src/GraphGenomeView/components/WalkKey.tsx +52 -0
  20. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +1 -9
  21. package/src/GraphGenomeView/components/WalkSelect.tsx +2 -2
  22. package/src/GraphGenomeView/components/legendStyles.ts +11 -0
  23. package/src/GraphGenomeView/components/usePaneGestures.ts +274 -0
  24. package/src/GraphGenomeView/model.test.ts +102 -10
  25. package/src/GraphGenomeView/model.ts +318 -119
  26. package/src/LinearGraphDisplay/model.ts +2 -0
  27. package/src/version.ts +1 -1
  28. package/dist/chunks/chunk-3FGJLRG2.js +0 -2
  29. package/dist/chunks/chunk-3FGJLRG2.js.map +0 -7
  30. package/dist/chunks/chunk-3SZJ4AFI.js +0 -3
  31. package/dist/chunks/chunk-3SZJ4AFI.js.map +0 -7
  32. /package/dist/chunks/{GraphGenomeView-CLUMXH4K.js.map → GraphGenomeView-QXKHSZQJ.js.map} +0 -0
  33. /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.21",
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.16"
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={contigThickness * 0.55}
57
- strokeLinecap="round"
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, useState } from 'react'
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={pathLegendRowStyle}>
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
- const walkSwatchStyle = { width: 26, height: 8, borderRadius: 2, flex: 'none' }
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 labelOf = (name: string) =>
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
- const delta =
208
- w.referenceBp === undefined
209
- ? ''
210
- : w.bp === w.referenceBp
211
- ? ', the reference length'
212
- : `, ${w.bp > w.referenceBp ? '+' : '−'}${Math.abs(w.bp - w.referenceBp).toLocaleString()} bp against the reference`
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
- paler nodes: on none of{' '}
237
- {lift.walks.length > 1 ? 'these walks' : 'this walk'}
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
- const domain = model.referenceRampDomain
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
- // Where the pointer was last, and whether it has travelled since mousedown —
546
- // per-gesture scratch that nothing renders from, which is what a ref is for.
547
- // Whether a drag is in progress is model state (`isPanning`/`draggingNode`),
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
- onMouseDown={handleMouseDown}
806
- onMouseMove={handleMouseMove}
807
- onMouseUp={handleMouseUp}
808
- onMouseLeave={handleMouseLeave}
809
- onClick={handleClick}
810
- onContextMenu={handleContextMenu}
533
+ {...handlers}
811
534
  />
812
535
 
813
- <TubeMapOverlay model={model} />
814
- <ReferenceStripOverlay model={model} />
815
- <RowLabels model={model} />
816
- <GraphSizeLabels model={model} />
817
- <BubbleHalos model={model} />
818
- <GenePins model={model} />
819
- <LabelLayer model={model} />
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