jbrowse-plugin-graphgenomeviewer 4.2.0 → 4.3.0

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/dist/chunks/{GraphGenomeView-QOBHGMLE.js → GraphGenomeView-ZB4N6Q3H.js} +2 -2
  2. package/dist/chunks/{GraphTrackSettingsDialog-RTSJTM6A.js → GraphTrackSettingsDialog-LXYUYPZS.js} +2 -2
  3. package/dist/chunks/{LinearGraphDisplay-TPXW6POY.js → LinearGraphDisplay-BCTYOYQ5.js} +2 -2
  4. package/dist/chunks/{chunk-4C4CMI6K.js → chunk-CNFN6EVE.js} +2 -2
  5. package/dist/chunks/chunk-PPYB6N2Q.js +2 -0
  6. package/dist/chunks/chunk-PPYB6N2Q.js.map +7 -0
  7. package/dist/chunks/chunk-PSOLXUFN.js +3 -0
  8. package/dist/chunks/chunk-PSOLXUFN.js.map +7 -0
  9. package/dist/chunks/chunk-UTWFU4X6.js +2 -0
  10. package/dist/chunks/chunk-UTWFU4X6.js.map +7 -0
  11. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +5 -5
  12. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  13. package/package.json +2 -2
  14. package/src/GraphGenomeView/components/GraphCanvas.tsx +6 -53
  15. package/src/GraphGenomeView/components/WalkRowContextMenu.tsx +63 -0
  16. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +1 -1
  17. package/src/GraphGenomeView/components/WalkStrip.tsx +207 -0
  18. package/src/GraphGenomeView/components/WalkStripLocator.tsx +49 -0
  19. package/src/GraphGenomeView/components/usePaneGestures.ts +1 -0
  20. package/src/GraphGenomeView/model.test.ts +42 -3
  21. package/src/GraphGenomeView/model.ts +139 -13
  22. package/src/GraphGenomeView/viewModel.ts +23 -7
  23. package/src/version.ts +1 -1
  24. package/dist/chunks/chunk-ECKLYJWP.js +0 -2
  25. package/dist/chunks/chunk-ECKLYJWP.js.map +0 -7
  26. package/dist/chunks/chunk-HW55QCJG.js +0 -3
  27. package/dist/chunks/chunk-HW55QCJG.js.map +0 -7
  28. package/dist/chunks/chunk-SIP5IHWK.js +0 -2
  29. package/dist/chunks/chunk-SIP5IHWK.js.map +0 -7
  30. /package/dist/chunks/{GraphGenomeView-QOBHGMLE.js.map → GraphGenomeView-ZB4N6Q3H.js.map} +0 -0
  31. /package/dist/chunks/{GraphTrackSettingsDialog-RTSJTM6A.js.map → GraphTrackSettingsDialog-LXYUYPZS.js.map} +0 -0
  32. /package/dist/chunks/{LinearGraphDisplay-TPXW6POY.js.map → LinearGraphDisplay-BCTYOYQ5.js.map} +0 -0
  33. /package/dist/chunks/{chunk-4C4CMI6K.js.map → chunk-CNFN6EVE.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
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": "4.2.0"
79
+ "@jbrowse/bandage-core": "4.3.0"
80
80
  },
81
81
  "peerDependencies": {
82
82
  "react": ">=18.0.0",
@@ -11,7 +11,6 @@ import { ErrorBanner, LoadingOverlay, Menu } from '@jbrowse/core/ui'
11
11
  import { isAlive } from '@jbrowse/mobx-state-tree'
12
12
  import { useRenderingBackend } from '@jbrowse/render-core/useRenderingBackend'
13
13
  import InfoIcon from '@mui/icons-material/Info'
14
- import LineStyleIcon from '@mui/icons-material/LineStyle'
15
14
  import { observer } from 'mobx-react'
16
15
 
17
16
  import BubbleHalos, { HaloLegend } from './BubbleHalos'
@@ -25,7 +24,10 @@ import ReferenceStripOverlay, {
25
24
  import TubeMapOverlay, { TubeMapLegend } from './TubeMapOverlay'
26
25
  import UnpopButton from './UnpopButton'
27
26
  import WalkKey, { walkSwatchStyle } from './WalkKey'
27
+ import { WalkRowContextMenu } from './WalkRowContextMenu'
28
28
  import WalkRowsOverlay, { WalkRowsLegend } from './WalkRowsOverlay'
29
+ import WalkStrip from './WalkStrip'
30
+ import WalkStripLocator from './WalkStripLocator'
29
31
  import { legendBoxStyle, legendRowStyle } from './legendStyles'
30
32
  import { usePaneGestures, useWheelZoom } from './usePaneGestures'
31
33
  import { locLabel, nodeOwnLocation } from '../../launchFromGraph/contributors'
@@ -552,58 +554,6 @@ const NodeContextMenu = observer(function NodeContextMenu({
552
554
  )
553
555
  })
554
556
 
555
- // A walk row's bar stands for a span of its haplotype's own contig, so the
556
- // question a right-click asks is answered by a linear view on that assembly,
557
- // with its genes
558
- const WalkRowContextMenu = observer(function WalkRowContextMenu({
559
- model,
560
- row,
561
- top,
562
- left,
563
- onClose,
564
- }: {
565
- model: GraphPaneModel
566
- row: number
567
- top: number
568
- left: number
569
- onClose: () => void
570
- }) {
571
- const target = model.walkRowLaunchTarget(row)
572
- const assembly = target?.assembly
573
- return (
574
- <Menu
575
- open
576
- anchorReference="anchorPosition"
577
- anchorPosition={{ top, left }}
578
- onClose={() => {
579
- onClose()
580
- }}
581
- onMenuItemClick={callback => {
582
- callback()
583
- }}
584
- menuItems={[
585
- target && assembly
586
- ? {
587
- label: `Linear genome view — ${assembly} ${locLabel(target.location)}`,
588
- icon: LineStyleIcon,
589
- onClick: () => {
590
- model.showInLinearView({ location: target.location, assembly })
591
- },
592
- }
593
- : {
594
- label: 'Linear genome view',
595
- icon: LineStyleIcon,
596
- disabled: true,
597
- disabledHelpText: target
598
- ? `No assembly in this session is ${target.location.haplotype ?? target.label}`
599
- : 'This walk states no contig coordinates',
600
- onClick: () => {},
601
- },
602
- ]}
603
- />
604
- )
605
- })
606
-
607
557
  // `ownChrome` false for a pane inside a track, whose controls, loading state
608
558
  // and errors are the track's. A hosted pane whose x the host places takes no pan or wheel of its
609
559
  // own: those are the linear view's, as on any other track. One drawing its
@@ -676,6 +626,7 @@ const GraphCanvas = observer(function GraphCanvas({
676
626
  <GenePins model={model} />
677
627
  <LabelLayer model={model} />
678
628
  <WalkRowsOverlay model={model} />
629
+ <WalkStripLocator model={model} />
679
630
  <Legends model={model} />
680
631
  </>
681
632
  )}
@@ -699,6 +650,8 @@ const GraphCanvas = observer(function GraphCanvas({
699
650
  ) : null}
700
651
  </div>
701
652
 
653
+ {model.walkStripShown ? <WalkStrip model={model} /> : null}
654
+
702
655
  <HoverTooltips model={model} />
703
656
 
704
657
  {contextNode ? (
@@ -0,0 +1,63 @@
1
+ import { Menu } from '@jbrowse/core/ui'
2
+ import LineStyleIcon from '@mui/icons-material/LineStyle'
3
+ import { observer } from 'mobx-react'
4
+
5
+ import { locLabel } from '../../launchFromGraph/contributors'
6
+
7
+ import type { GraphPaneModel } from '../model'
8
+ import type { WalkRows } from '@jbrowse/bandage-core/layout/walkRows'
9
+
10
+ // A walk row's bar stands for a span of its haplotype's own contig, so the
11
+ // question a right-click asks is answered by a linear view on that assembly,
12
+ // with its genes
13
+ export const WalkRowContextMenu = observer(function WalkRowContextMenu({
14
+ model,
15
+ row,
16
+ bars,
17
+ top,
18
+ left,
19
+ onClose,
20
+ }: {
21
+ model: GraphPaneModel
22
+ row: number
23
+ // the rows `row` indexes, walk rows' own unless a strip's
24
+ bars?: WalkRows
25
+ top: number
26
+ left: number
27
+ onClose: () => void
28
+ }) {
29
+ const target = model.walkRowLaunchTarget(row, bars ?? model.walkRowBars)
30
+ const assembly = target?.assembly
31
+ return (
32
+ <Menu
33
+ open
34
+ anchorReference="anchorPosition"
35
+ anchorPosition={{ top, left }}
36
+ onClose={() => {
37
+ onClose()
38
+ }}
39
+ onMenuItemClick={callback => {
40
+ callback()
41
+ }}
42
+ menuItems={[
43
+ target && assembly
44
+ ? {
45
+ label: `Linear genome view — ${assembly} ${locLabel(target.location)}`,
46
+ icon: LineStyleIcon,
47
+ onClick: () => {
48
+ model.showInLinearView({ location: target.location, assembly })
49
+ },
50
+ }
51
+ : {
52
+ label: 'Linear genome view',
53
+ icon: LineStyleIcon,
54
+ disabled: true,
55
+ disabledHelpText: target
56
+ ? `No assembly in this session is ${target.location.haplotype ?? target.label}`
57
+ : 'This walk states no contig coordinates',
58
+ onClick: () => {},
59
+ },
60
+ ]}
61
+ />
62
+ )
63
+ })
@@ -59,7 +59,7 @@ function TickSwatch({ color }: { color: string }) {
59
59
  )
60
60
  }
61
61
 
62
- function Swatch({ swatch }: { swatch: KeySwatch }) {
62
+ export function Swatch({ swatch }: { swatch: KeySwatch }) {
63
63
  return swatch.kind === 'gene' ? (
64
64
  <div
65
65
  style={{
@@ -0,0 +1,207 @@
1
+ import { useId, useMemo, useState } from 'react'
2
+
3
+ import {
4
+ walkRowReadout,
5
+ walkRowsKey,
6
+ walkRowsTree,
7
+ } from '@jbrowse/bandage-core/layout/walkRowDraw'
8
+ import {
9
+ stripRowAt,
10
+ walkMarksTree,
11
+ } from '@jbrowse/bandage-core/layout/walkStrip'
12
+ import { RAMP_GRADIENT_CSS } from '@jbrowse/bandage-core/referenceRampCss'
13
+ import { isAlive } from '@jbrowse/mobx-state-tree'
14
+ import { observer } from 'mobx-react'
15
+
16
+ import ElTree from './ElTree'
17
+ import { WalkRowContextMenu } from './WalkRowContextMenu'
18
+ import { Swatch } from './WalkRowsOverlay'
19
+ import { legendRowStyle } from './legendStyles'
20
+
21
+ import type { GraphPaneModel } from '../model'
22
+ import type { WalkRows } from '@jbrowse/bandage-core/layout/walkRows'
23
+ import type { StripFrame } from '@jbrowse/bandage-core/layout/walkStrip'
24
+
25
+ // Walk rows under a layout that draws nodes, each haplotype's walk on its own
26
+ // bp, linked to the drawing above: the hovered node ticks every bar where its
27
+ // walk passes it, and a point on a bar lights its node, ringed, above. The
28
+ // bars share the drawing's colours, so under the reference-position ramp a hue
29
+ // in the drawing is the same hue on each bar. Every row is shown; rows too
30
+ // thin to letter say who they are on hover.
31
+
32
+ const keyStyle = {
33
+ display: 'flex',
34
+ flexWrap: 'wrap' as const,
35
+ gap: '2px 14px',
36
+ padding: '2px 8px 6px',
37
+ fontSize: 11,
38
+ color: '#333',
39
+ }
40
+
41
+ const tipStyle = {
42
+ position: 'absolute' as const,
43
+ pointerEvents: 'none' as const,
44
+ background: 'rgba(255,255,255,0.92)',
45
+ border: '1px solid #ccc',
46
+ borderRadius: 3,
47
+ padding: '1px 6px',
48
+ fontSize: 11,
49
+ whiteSpace: 'nowrap' as const,
50
+ zIndex: 4,
51
+ }
52
+
53
+ // The bars, built once per rows, frame and colouring rather than per hover
54
+ const StripBars = observer(function StripBars({
55
+ bars,
56
+ frame,
57
+ ramp,
58
+ }: {
59
+ bars: WalkRows
60
+ frame: StripFrame
61
+ ramp: { start: number; end: number } | undefined
62
+ }) {
63
+ const idPrefix = useId().replace(/[^\w-]/g, '')
64
+ const tree = useMemo(
65
+ () => walkRowsTree(bars, frame, { ramp, idPrefix }),
66
+ [bars, frame, ramp, idPrefix],
67
+ )
68
+ return <ElTree el={tree} />
69
+ })
70
+
71
+ const WalkStrip = observer(function WalkStrip({
72
+ model,
73
+ }: {
74
+ model: GraphPaneModel
75
+ }) {
76
+ const bars = model.walkStripRows
77
+ const frame = model.walkStripFrame
78
+ const [menu, setMenu] = useState<
79
+ { row: number; top: number; left: number } | undefined
80
+ >(undefined)
81
+ const [tip, setTip] = useState<
82
+ { text: string; x: number; y: number } | undefined
83
+ >(undefined)
84
+ if (!bars || !frame) {
85
+ return null
86
+ }
87
+ const rows = [bars.reference, ...bars.rows]
88
+ const hit = (e: React.MouseEvent<SVGSVGElement>) => {
89
+ const box = e.currentTarget.getBoundingClientRect()
90
+ const x = e.clientX - box.left
91
+ const y = e.clientY - box.top
92
+ return { ...stripRowAt(bars, frame, x, y), x, y }
93
+ }
94
+ const key = walkRowsKey(bars, {
95
+ ramp: model.referenceRampDomain,
96
+ rampCss: RAMP_GRADIENT_CSS,
97
+ })
98
+ return (
99
+ <div
100
+ data-testid="graph-walk-strip"
101
+ style={{ position: 'relative', borderTop: '1px solid #ddd' }}
102
+ onMouseEnter={() => {
103
+ model.setPointerInPane(true)
104
+ }}
105
+ onMouseLeave={() => {
106
+ if (isAlive(model)) {
107
+ model.setPointerInPane(false)
108
+ model.setStripHover(null)
109
+ }
110
+ setTip(undefined)
111
+ }}
112
+ >
113
+ <svg
114
+ width={frame.width}
115
+ height={frame.height}
116
+ style={{ display: 'block', cursor: 'pointer' }}
117
+ onMouseMove={e => {
118
+ const at = hit(e)
119
+ if (at.row) {
120
+ model.setStripHover({ row: at.row.name, offset: at.offset! })
121
+ setTip(
122
+ frame.labelled
123
+ ? undefined
124
+ : {
125
+ text: `${at.row.label} · ${walkRowReadout(
126
+ at.row,
127
+ at.index === 0 ? undefined : bars.reference,
128
+ )}`,
129
+ x: at.x,
130
+ y: at.y,
131
+ },
132
+ )
133
+ } else {
134
+ model.setStripHover(null)
135
+ setTip(undefined)
136
+ }
137
+ }}
138
+ onClick={e => {
139
+ const at = hit(e)
140
+ if (at.row && at.index! > 0) {
141
+ model.toggleWalk(at.row.name)
142
+ }
143
+ }}
144
+ onContextMenu={e => {
145
+ const at = hit(e)
146
+ if (at.row) {
147
+ e.preventDefault()
148
+ setMenu({ row: at.index!, top: e.clientY, left: e.clientX })
149
+ }
150
+ }}
151
+ >
152
+ <StripBars bars={bars} frame={frame} ramp={model.referenceRampDomain} />
153
+ <ElTree el={walkMarksTree(bars, frame, model.walkStripMarks)} />
154
+ {frame.labelled
155
+ ? rows.map((row, i) => (
156
+ <text
157
+ key={row.name}
158
+ x={6}
159
+ y={i * frame.rowPx + frame.translateY + 4}
160
+ fontSize={11}
161
+ fontFamily="sans-serif"
162
+ fill="#333"
163
+ fontWeight={
164
+ model.walkLayers.some(l => l.walk === row.name) ? 600 : 400
165
+ }
166
+ >
167
+ {row.label}
168
+ </text>
169
+ ))
170
+ : null}
171
+ </svg>
172
+ {tip ? (
173
+ <div style={{ ...tipStyle, left: tip.x + 12, top: tip.y - 18 }}>
174
+ {tip.text}
175
+ </div>
176
+ ) : null}
177
+ <div style={keyStyle} data-testid="graph-walk-strip-key">
178
+ {key.map(entry => (
179
+ <div key={entry.label} style={legendRowStyle}>
180
+ <Swatch swatch={entry.swatch} />
181
+ <span>{entry.label}</span>
182
+ </div>
183
+ ))}
184
+ <div style={legendRowStyle}>
185
+ <span>
186
+ ▮ ticks: where each walk passes the node under the pointer · click a
187
+ bar to lift its walk
188
+ </span>
189
+ </div>
190
+ </div>
191
+ {menu ? (
192
+ <WalkRowContextMenu
193
+ model={model}
194
+ row={menu.row}
195
+ bars={bars}
196
+ top={menu.top}
197
+ left={menu.left}
198
+ onClose={() => {
199
+ setMenu(undefined)
200
+ }}
201
+ />
202
+ ) : null}
203
+ </div>
204
+ )
205
+ })
206
+
207
+ export default WalkStrip
@@ -0,0 +1,49 @@
1
+ import { observer } from 'mobx-react'
2
+
3
+ import type { GraphPaneModel } from '../model'
4
+
5
+ const svgStyle = {
6
+ position: 'absolute' as const,
7
+ left: 0,
8
+ top: 0,
9
+ pointerEvents: 'none' as const,
10
+ overflow: 'visible' as const,
11
+ zIndex: 4,
12
+ }
13
+
14
+ // A ring round the node a point on the walk strip lights, so a one-bp node
15
+ // can be found in a hairball
16
+ const WalkStripLocator = observer(function WalkStripLocator({
17
+ model,
18
+ }: {
19
+ model: GraphPaneModel
20
+ }) {
21
+ const at = model.walkStripLocator
22
+ return at ? (
23
+ <svg
24
+ style={svgStyle}
25
+ width={model.paneWidth}
26
+ height={model.canvasHeight}
27
+ data-testid="graph-walk-strip-locator"
28
+ >
29
+ <circle
30
+ cx={at.x}
31
+ cy={at.y}
32
+ r={11}
33
+ fill="none"
34
+ stroke="white"
35
+ strokeWidth={4}
36
+ />
37
+ <circle
38
+ cx={at.x}
39
+ cy={at.y}
40
+ r={11}
41
+ fill="none"
42
+ stroke="#111"
43
+ strokeWidth={1.75}
44
+ />
45
+ </svg>
46
+ ) : null
47
+ })
48
+
49
+ export default WalkStripLocator
@@ -171,6 +171,7 @@ export function usePaneGestures(model: GraphPaneModel) {
171
171
  model.positionsVersion,
172
172
  model.deletionEdgeIndexes,
173
173
  model.hiddenEdgeIndexes,
174
+ model.deletionRoutes,
174
175
  ),
175
176
  )
176
177
  }
@@ -2335,12 +2335,11 @@ describe('what the row axis draws, in pixels', () => {
2335
2335
  })
2336
2336
 
2337
2337
  test('a deletion edge draws only in a view that shows it', async () => {
2338
- const hidden = await fitted()
2339
- expect(hidden.showDeletionEdges).toBe(false)
2338
+ const hidden = await fitted({ showDeletionEdges: false })
2340
2339
  expect([...hidden.hiddenEdgeIndexes]).toEqual(
2341
2340
  hidden.deletions.map(d => d.edgeIndex),
2342
2341
  )
2343
- const shown = await fitted({ showDeletionEdges: true })
2342
+ const shown = await fitted()
2344
2343
  expect(getSnapshot(shown).showDeletionEdges).toBe(true)
2345
2344
  expect(shown.hiddenEdgeIndexes.size).toBe(0)
2346
2345
 
@@ -2654,6 +2653,46 @@ describe('walk rows', () => {
2654
2653
  '',
2655
2654
  ].join('\n')
2656
2655
 
2656
+ test('the walk strip shows under a node layout, and links bars and nodes both ways', async () => {
2657
+ rpcRespond()
2658
+ const model = stateModelFactory().create({
2659
+ type: 'GraphGenomeView',
2660
+ layoutMode: 'ordered',
2661
+ walkStrip: true,
2662
+ referencePath: 'GRCh38',
2663
+ })
2664
+ await model.loadGFA(WALKS_GFA, 'walks')
2665
+ model.setWidth(800)
2666
+ expect(model.walkStripShown).toBe(true)
2667
+ // the layout keeps its own drawing: walk rows' bars are not the strip's
2668
+ expect(model.walkRowBars).toBeUndefined()
2669
+ const bars = model.walkStripRows!
2670
+ expect(bars.rows.map(r => r.label).sort()).toEqual(['A#1', 'A#2', 'B#1'])
2671
+ const two = model.graph!.nodes.find(n => n.name === '2')!
2672
+ // a point on B#1's bar inside node 2 lights it
2673
+ model.setStripHover({ row: 'B#1#ctg', offset: 6 })
2674
+ expect(model.hoveredNode).toBe(two.id)
2675
+ // and node 2 ticks the bars of the walks through it, not A#2's
2676
+ const marked = new Set(model.walkStripMarks.map(m => m.row))
2677
+ expect([...marked].sort()).toEqual(['A#1#ctg', 'B#1#ctg'])
2678
+ model.setStripHover(null)
2679
+ expect(model.hoveredNode).toBeNull()
2680
+ model.setWalkStrip(false)
2681
+ expect(model.walkStripShown).toBe(false)
2682
+ expect(model.walkStripRows).toBeUndefined()
2683
+ })
2684
+
2685
+ test('walk rows and tube maps take no strip', async () => {
2686
+ rpcRespond()
2687
+ const model = stateModelFactory().create({
2688
+ type: 'GraphGenomeView',
2689
+ layoutMode: 'walkrows',
2690
+ walkStrip: true,
2691
+ })
2692
+ await model.loadGFA(WALKS_GFA, 'walks')
2693
+ expect(model.walkStripShown).toBe(false)
2694
+ })
2695
+
2657
2696
  test('a sample filter keeps its walks, paired in the order named, and labels follow', async () => {
2658
2697
  rpcRespond()
2659
2698
  const model = stateModelFactory().create({