jbrowse-plugin-graphgenomeviewer 4.0.19 → 4.0.21

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 (38) hide show
  1. package/README.md +2 -1
  2. package/dist/chunks/{GbzBaseSyntenyAdapter-KKSNCZXY.js → GbzBaseSyntenyAdapter-7ZKFAEJG.js} +2 -2
  3. package/dist/chunks/{GbzBaseSyntenyAdapter-KKSNCZXY.js.map → GbzBaseSyntenyAdapter-7ZKFAEJG.js.map} +2 -2
  4. package/dist/chunks/{GraphGenomeView-37F7MLFQ.js → GraphGenomeView-CLUMXH4K.js} +2 -2
  5. package/dist/chunks/{GraphTrackSettingsDialog-2QV6TPX7.js → GraphTrackSettingsDialog-QF5OMTCH.js} +2 -2
  6. package/dist/chunks/{LinearGraphDisplay-CJHA56RB.js → LinearGraphDisplay-4CMVCEM5.js} +2 -2
  7. package/dist/chunks/chunk-3FGJLRG2.js +2 -0
  8. package/dist/chunks/chunk-3FGJLRG2.js.map +7 -0
  9. package/dist/chunks/chunk-3SZJ4AFI.js +3 -0
  10. package/dist/chunks/chunk-3SZJ4AFI.js.map +7 -0
  11. package/dist/chunks/{chunk-5E7TVPCI.js → chunk-CLRXQ6BM.js} +2 -2
  12. package/dist/chunks/chunk-VIFXYTIS.js +2 -0
  13. package/dist/chunks/chunk-VIFXYTIS.js.map +7 -0
  14. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  15. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +3 -3
  16. package/package.json +2 -2
  17. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.ts +1 -1
  18. package/src/GetSubgraph.ts +3 -0
  19. package/src/GraphGenomeView/components/BubbleHalos.tsx +3 -4
  20. package/src/GraphGenomeView/components/GraphCanvas.tsx +51 -15
  21. package/src/GraphGenomeView/components/LabelLayer.tsx +5 -6
  22. package/src/GraphGenomeView/components/WalkSelect.tsx +26 -13
  23. package/src/GraphGenomeView/model.test.ts +48 -0
  24. package/src/GraphGenomeView/model.ts +75 -21
  25. package/src/GraphGenomeView/viewModel.ts +15 -0
  26. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +23 -2
  27. package/src/LinearGraphDisplay/model.ts +7 -2
  28. package/src/version.ts +1 -1
  29. package/dist/chunks/chunk-BI3M6FZD.js +0 -2
  30. package/dist/chunks/chunk-BI3M6FZD.js.map +0 -7
  31. package/dist/chunks/chunk-IUIIYSGF.js +0 -2
  32. package/dist/chunks/chunk-IUIIYSGF.js.map +0 -7
  33. package/dist/chunks/chunk-MNOQXDMY.js +0 -3
  34. package/dist/chunks/chunk-MNOQXDMY.js.map +0 -7
  35. /package/dist/chunks/{GraphGenomeView-37F7MLFQ.js.map → GraphGenomeView-CLUMXH4K.js.map} +0 -0
  36. /package/dist/chunks/{GraphTrackSettingsDialog-2QV6TPX7.js.map → GraphTrackSettingsDialog-QF5OMTCH.js.map} +0 -0
  37. /package/dist/chunks/{LinearGraphDisplay-CJHA56RB.js.map → LinearGraphDisplay-4CMVCEM5.js.map} +0 -0
  38. /package/dist/chunks/{chunk-5E7TVPCI.js.map → chunk-CLRXQ6BM.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "4.0.19",
3
+ "version": "4.0.21",
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.14"
79
+ "@jbrowse/bandage-core": "0.1.16"
80
80
  },
81
81
  "peerDependencies": {
82
82
  "react": ">=18.0.0",
@@ -381,7 +381,7 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
381
381
  const keep = this.keepPredicate(opts.haplotypes)
382
382
  return cutWindowGFA(db, query, start, end, {
383
383
  context: this.getConf('context'),
384
- snarls: this.getConf('subgraphSnarls'),
384
+ snarls: opts.snarls ?? this.getConf('subgraphSnarls'),
385
385
  limit: this.getConf('nodeLimit'),
386
386
  signal: opts.signal,
387
387
  ...(keep === undefined ? {} : { keep }),
@@ -20,6 +20,9 @@ export interface SubgraphCutOptions {
20
20
  hops?: number
21
21
  haplotypes?: string[]
22
22
  tier?: SubgraphTier
23
+ // which snarls a GBZ cut follows past the window, over the track's
24
+ // `subgraphSnarls`; 'overlapping' when the layout measures whole walks
25
+ snarls?: 'none' | 'contained' | 'overlapping'
23
26
  }
24
27
 
25
28
  // What an adapter's cut is handed: the payload, plus the call's own signal so
@@ -28,7 +28,7 @@ const BubbleHalos = observer(function BubbleHalos({
28
28
  }: {
29
29
  model: GraphPaneModel
30
30
  }) {
31
- const { bubbleHalos, walkHighlight, hoveredBubble } = model
31
+ const { bubbleHalos, walkLift, hoveredBubble } = model
32
32
  if (bubbleHalos.length === 0) {
33
33
  return null
34
34
  }
@@ -41,10 +41,9 @@ const BubbleHalos = observer(function BubbleHalos({
41
41
  canvasHeight,
42
42
  } = model
43
43
  const halo = model.contigThickness * HALO_FACTOR
44
- // a lifted walk dims the bubbles it never enters
44
+ // lifted walks dim the bubbles none of them enters
45
45
  const dimmedBubble = (h: BubbleHalo) =>
46
- walkHighlight !== undefined &&
47
- !h.nodeIds.some(id => walkHighlight.nodeIds.has(id))
46
+ walkLift !== undefined && !h.nodeIds.some(id => walkLift.nodeIds.has(id))
48
47
 
49
48
  return (
50
49
  <svg
@@ -7,6 +7,11 @@ import {
7
7
  findHoveredNode,
8
8
  } from '@jbrowse/bandage-core/util/hitDetection'
9
9
  import { wheelZoomFactor } from '@jbrowse/bandage-core/util/wheelZoom'
10
+ import {
11
+ WALK_FIELDS,
12
+ encodingSwatchCss,
13
+ fieldLegend,
14
+ } from '@jbrowse/bandage-core/walkEncoding'
10
15
  import { ErrorBanner, LoadingOverlay, Menu } from '@jbrowse/core/ui'
11
16
  import { isAlive } from '@jbrowse/mobx-state-tree'
12
17
  import { useRenderingBackend } from '@jbrowse/render-core/useRenderingBackend'
@@ -176,30 +181,61 @@ const PathLegend = observer(function PathLegend({
176
181
  ) : null
177
182
  })
178
183
 
179
- // What the lifted walk carries through the window, against the reference walk
180
- // where the graph has one: the number the array's loops are drawn for.
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
+ }
192
+
181
193
  const WalkReadout = observer(function WalkReadout({
182
194
  model,
183
195
  }: {
184
196
  model: GraphPaneModel
185
197
  }) {
186
- const h = model.walkHighlight
187
- if (!h) {
198
+ const lift = model.walkLift
199
+ if (!lift) {
188
200
  return null
189
201
  }
190
- const label = model.walkChoices.find(c => c.name === h.name)?.label ?? h.name
191
- const delta =
192
- h.referenceBp === undefined
193
- ? ''
194
- : h.bp === h.referenceBp
195
- ? ', the reference length'
196
- : `, ${h.bp > h.referenceBp ? '+' : '−'}${Math.abs(h.bp - h.referenceBp).toLocaleString()} bp against the reference`
202
+ const labelOf = (name: string) =>
203
+ model.walkChoices.find(c => c.name === name)?.label ?? name
197
204
  return (
198
205
  <div style={legendBoxStyle} data-testid="graph-walk-readout">
199
- <strong>{label}</strong>: {h.steps.toLocaleString()} steps,{' '}
200
- {h.bp.toLocaleString()} bp{delta}
201
- <br />
202
- paler: what {label} does not carry
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>
234
+ ))}
235
+ <div>
236
+ paler nodes: on none of{' '}
237
+ {lift.walks.length > 1 ? 'these walks' : 'this walk'}
238
+ </div>
203
239
  </div>
204
240
  )
205
241
  })
@@ -34,14 +34,13 @@ const LabelLayer = observer(function LabelLayer({
34
34
  if (bubbles.length + genes.length + routes.length === 0) {
35
35
  return null
36
36
  }
37
- const { walkHighlight, scaleY, translateY, contigThickness } = model
38
- // a lifted walk dims the bubbles it never enters and the routes it does not
39
- // take
37
+ const { walkLift, scaleY, translateY, contigThickness } = model
38
+ // lifted walks dim the bubbles none of them enters and the routes none of
39
+ // them takes
40
40
  const dimmedBubble = (h: BubbleHalo) =>
41
- walkHighlight !== undefined &&
42
- !h.nodeIds.some(id => walkHighlight.nodeIds.has(id))
41
+ walkLift !== undefined && !h.nodeIds.some(id => walkLift.nodeIds.has(id))
43
42
  const dimmedRoute = (r: RouteLabel) =>
44
- walkHighlight !== undefined && !r.route.walks.includes(walkHighlight.name)
43
+ walkLift !== undefined && !r.route.walks.some(w => walkLift.names.has(w))
45
44
 
46
45
  return (
47
46
  <svg
@@ -1,4 +1,11 @@
1
- import { FormControl, InputLabel, MenuItem, Select } from '@mui/material'
1
+ import {
2
+ Checkbox,
3
+ FormControl,
4
+ InputLabel,
5
+ ListItemText,
6
+ MenuItem,
7
+ Select,
8
+ } from '@mui/material'
2
9
  import { observer } from 'mobx-react'
3
10
  import { makeStyles } from 'tss-react/mui'
4
11
 
@@ -7,12 +14,13 @@ import type { GraphPaneModel } from '../model'
7
14
  const useStyles = makeStyles()({
8
15
  formControl: {
9
16
  minWidth: 130,
10
- maxWidth: 220,
17
+ maxWidth: 260,
11
18
  },
12
19
  })
13
20
 
14
- // Which walk to lift out of the drawing: Bandage's path highlight, with the
15
- // haplotypes a GBZ cut carries. Absent from a graph with no walks.
21
+ // Which walks to lift out of the drawing: Bandage's path highlight, for as many
22
+ // of the haplotypes a GBZ cut carries as the reader picks. Absent from a graph
23
+ // with no walks.
16
24
  const WalkSelect = observer(function WalkSelect({
17
25
  model,
18
26
  }: {
@@ -24,23 +32,28 @@ const WalkSelect = observer(function WalkSelect({
24
32
  return null
25
33
  }
26
34
  const names = new Set(walkChoices.map(c => c.name))
35
+ const labels = new Map(walkChoices.map(c => [c.name, c.label]))
36
+ const picked = model.walkLayers
37
+ .map(layer => layer.walk)
38
+ .filter(name => names.has(name))
27
39
  return (
28
40
  <FormControl size="small" className={classes.formControl}>
29
- <InputLabel>Walk</InputLabel>
41
+ <InputLabel>Walks</InputLabel>
30
42
  <Select
31
- value={names.has(model.highlightedPath) ? model.highlightedPath : ''}
32
- label="Walk"
43
+ multiple
44
+ value={picked}
45
+ label="Walks"
33
46
  data-testid="graph-walk-select"
47
+ renderValue={selected => selected.map(n => labels.get(n)).join(', ')}
34
48
  onChange={e => {
35
- model.setHighlightedPath(e.target.value)
49
+ const { value } = e.target
50
+ model.liftWalks(typeof value === 'string' ? value.split(',') : value)
36
51
  }}
37
52
  >
38
- <MenuItem value="">
39
- <em>None</em>
40
- </MenuItem>
41
53
  {walkChoices.map(({ name, label }) => (
42
- <MenuItem key={name} value={name}>
43
- {label}
54
+ <MenuItem key={name} value={name} dense>
55
+ <Checkbox size="small" checked={picked.includes(name)} />
56
+ <ListItemText primary={label} />
44
57
  </MenuItem>
45
58
  ))}
46
59
  </Select>
@@ -2484,6 +2484,54 @@ describe('walk rows', () => {
2484
2484
  model.setWalkRowSamples(['B'])
2485
2485
  expect(model.walkRowBars!.rows.map(r => r.label)).toEqual(['B#1'])
2486
2486
  })
2487
+
2488
+ test('walks lift together, each keeping the colour it was given', async () => {
2489
+ rpcRespond()
2490
+ const model = stateModelFactory().create({
2491
+ type: 'GraphGenomeView',
2492
+ layoutMode: 'auto',
2493
+ colorScheme: 'grey',
2494
+ })
2495
+ await model.loadGFA(WALKS_GFA, 'walks')
2496
+ model.liftWalks(['B#1#ctg', 'GRCh38#0#chr1'])
2497
+ model.setWalkColor('B#1#ctg', { field: 'strand' })
2498
+ model.setWalkColor('B#1#ctg', { scheme: 'greens' })
2499
+ model.toggleWalk('A#1#ctg')
2500
+ expect(model.walkLift!.walks.map(w => [w.name, w.encoding])).toEqual([
2501
+ ['GRCh38#0#chr1', { field: 'reference', scheme: 'rainbow' }],
2502
+ ['B#1#ctg', { field: 'strand', scheme: 'greens' }],
2503
+ ['A#1#ctg', { field: 'progress', scheme: 'reds' }],
2504
+ ])
2505
+ // the reference is coloured by position whatever the node colour scheme
2506
+ const ref = model.walkLift!.walks[0]!
2507
+ expect(new Set(ref.colors.values()).size).toBe(2)
2508
+ interface Item {
2509
+ label?: string
2510
+ checked?: boolean
2511
+ subMenu?: Item[]
2512
+ }
2513
+ const walkMenu = (model.graphMenuItems() as Item[]).find(
2514
+ item => item.label === 'Walk',
2515
+ )!
2516
+ const colour = walkMenu.subMenu!.find(item => item.label === 'Colour B#1')!
2517
+ expect(
2518
+ colour.subMenu.filter(item => item.checked).map(item => item.label),
2519
+ ).toEqual(['Strand against the reference', 'Greens'])
2520
+ model.liftWalks(['B#1#ctg'])
2521
+ expect(model.walkLayers).toEqual([
2522
+ { walk: 'B#1#ctg', color: { field: 'strand', scheme: 'greens' } },
2523
+ ])
2524
+ model.toggleWalk('B#1#ctg')
2525
+ expect(model.walkLift).toBeUndefined()
2526
+ })
2527
+
2528
+ test('a session naming the one walk the old field lifted opens with none', () => {
2529
+ const model = stateModelFactory().create({
2530
+ type: 'GraphGenomeView',
2531
+ highlightedPath: 'A#1#ctg',
2532
+ } as never)
2533
+ expect(model.walkLayers).toEqual([])
2534
+ })
2487
2535
  })
2488
2536
 
2489
2537
  describe('annotation reads beside a cut', () => {
@@ -97,7 +97,8 @@ import {
97
97
  viewportOf,
98
98
  zoomAbout,
99
99
  } from '@jbrowse/bandage-core/viewport'
100
- import { walkHighlight } from '@jbrowse/bandage-core/walkHighlight'
100
+ import { WALK_FIELDS, WALK_SCHEMES } from '@jbrowse/bandage-core/walkEncoding'
101
+ import { walkLift } from '@jbrowse/bandage-core/walkHighlight'
101
102
  import { readConfObject } from '@jbrowse/core/configuration'
102
103
  import { pushLaunchViewMenuItem } from '@jbrowse/core/ui'
103
104
  import {
@@ -179,6 +180,10 @@ import type {
179
180
  } from '@jbrowse/bandage-core/types'
180
181
  import type { AxisScale } from '@jbrowse/bandage-core/util/geometry'
181
182
  import type { NodeInk } from '@jbrowse/bandage-core/util/hitDetection'
183
+ import type {
184
+ WalkEncoding,
185
+ WalkLayer,
186
+ } from '@jbrowse/bandage-core/walkEncoding'
182
187
  import type { AnyConfigurationModel } from '@jbrowse/core/configuration'
183
188
  import type { MenuItem } from '@jbrowse/core/ui'
184
189
  import type { Feature } from '@jbrowse/core/util'
@@ -440,9 +445,10 @@ export function GraphPaneMixin() {
440
445
  // Samples whose walks the walk rows show, by the name before the
441
446
  // haplotype number; undefined shows every walk the cut holds.
442
447
  walkRowSamples: types.maybe(types.frozen<string[]>()),
443
- // One walk, by its path name, lifted out of the drawing: its nodes and
444
- // links keep their ink and the rest fades. Empty lifts none.
445
- highlightedPath: types.optional(types.string, ''),
448
+ // Walks lifted out of the drawing, each a layer with a lane of its own
449
+ // and the rest fading, coloured by the encoding it states or by the
450
+ // default one. See walkEncoding.ts. Empty lifts none.
451
+ walkLayers: types.optional(types.frozen<WalkLayer[]>(), []),
446
452
  // Which of a general GFA's paths the anchored layouts put on x. A path
447
453
  // GFA's names are arbitrary and none of them is marked as the
448
454
  // reference, so this is a choice; empty means "infer", which is the
@@ -723,13 +729,6 @@ export function GraphPaneMixin() {
723
729
  const paths = self.graph?.paths
724
730
  return paths?.length ? pathLegend(paths) : []
725
731
  },
726
- // The lifted walk, or undefined when none is named or the graph on
727
- // screen does not carry the one that was.
728
- get walkHighlight() {
729
- return self.graph && self.highlightedPath
730
- ? walkHighlight(self.graph, self.highlightedPath)
731
- : undefined
732
- },
733
732
  // Whether the drawing is actually painted per path, which is not the same
734
733
  // question as whether the user asked for it: past MAX_PATH_COLORS the
735
734
  // colours say nothing and cost a stroke per path per edge. A bare getter
@@ -754,6 +753,19 @@ export function GraphPaneMixin() {
754
753
  get rampDomain() {
755
754
  return self.colorDomain ?? self.graphRegion
756
755
  },
756
+ // The lifted walks the graph on screen carries, or undefined when none
757
+ // is named or it carries none of those that were. A walk coloured by
758
+ // reference position reads the ramp whatever the node colour scheme.
759
+ get walkLift() {
760
+ const { graph } = self
761
+ return graph && self.walkLayers.length > 0
762
+ ? walkLift(
763
+ graph,
764
+ self.walkLayers,
765
+ computeReferenceRamp(graph, this.rampDomain),
766
+ )
767
+ : undefined
768
+ },
757
769
  // The scheme the renderer actually paints with, which is the raw prop
758
770
  // unless it is 'auto'. A bare getter returns a resolved value (root
759
771
  // CLAUDE.md): every consumer of a colour reads this one, and the two
@@ -1419,7 +1431,7 @@ export function GraphPaneMixin() {
1419
1431
  ? referenceStripBlocks(graph, {
1420
1432
  colorScheme: self.effectiveColorScheme,
1421
1433
  referenceRamp: self.referenceRamp,
1422
- walkNodes: self.walkHighlight?.nodeIds,
1434
+ walkNodes: self.walkLift?.nodeIds,
1423
1435
  })
1424
1436
  : []
1425
1437
  },
@@ -1765,8 +1777,25 @@ export function GraphPaneMixin() {
1765
1777
  setRepeatKey(key: string) {
1766
1778
  self.repeatKey = key
1767
1779
  },
1768
- setHighlightedPath(name: string) {
1769
- self.highlightedPath = name
1780
+ setWalkLayers(layers: WalkLayer[]) {
1781
+ self.walkLayers = layers
1782
+ },
1783
+ // Lift the walks named, in that order, keeping the colour any of them
1784
+ // already had
1785
+ liftWalks(names: string[]) {
1786
+ const had = new Map(self.walkLayers.map(l => [l.walk, l]))
1787
+ self.walkLayers = names.map(walk => had.get(walk) ?? { walk })
1788
+ },
1789
+ toggleWalk(walk: string) {
1790
+ const layers = self.walkLayers
1791
+ self.walkLayers = layers.some(l => l.walk === walk)
1792
+ ? layers.filter(l => l.walk !== walk)
1793
+ : [...layers, { walk }]
1794
+ },
1795
+ setWalkColor(walk: string, color: Partial<WalkEncoding>) {
1796
+ self.walkLayers = self.walkLayers.map(l =>
1797
+ l.walk === walk ? { ...l, color: { ...l.color, ...color } } : l,
1798
+ )
1770
1799
  },
1771
1800
  // Undefined restores the built-in ceiling. Nothing recomputes: the pane
1772
1801
  // reads canvasHeight and the drawing is placed by zoomToFit, which the
@@ -2816,7 +2845,7 @@ export function GraphPaneMixin() {
2816
2845
  connectorThickness: self.connectorThickness,
2817
2846
  drawPaths: self.effectiveDrawPaths,
2818
2847
  nodeWidth: self.nodeWidth,
2819
- highlight: self.walkHighlight,
2848
+ highlight: self.walkLift,
2820
2849
  // Untracked, so a zoom does not eagerly rebuild geometry — the
2821
2850
  // debounced viewportDirty bump drives the scale-dependent
2822
2851
  // rebuild (flatness, arrow visibility, viewport culling), same
@@ -3002,21 +3031,46 @@ export function GraphPaneMixin() {
3002
3031
  label: 'Walk',
3003
3032
  subMenu: [
3004
3033
  {
3005
- type: 'radio' as const,
3006
3034
  label: 'None',
3007
- checked: self.highlightedPath === '',
3008
3035
  onClick: () => {
3009
- self.setHighlightedPath('')
3036
+ self.setWalkLayers([])
3010
3037
  },
3011
3038
  },
3012
3039
  ...walks.map(walk => ({
3013
- type: 'radio' as const,
3040
+ type: 'checkbox' as const,
3014
3041
  label: walk.label,
3015
- checked: self.highlightedPath === walk.name,
3042
+ checked: self.walkLayers.some(l => l.walk === walk.name),
3016
3043
  onClick: () => {
3017
- self.setHighlightedPath(walk.name)
3044
+ self.toggleWalk(walk.name)
3018
3045
  },
3019
3046
  })),
3047
+ ...(self.walkLift?.walks ?? []).map(lifted => ({
3048
+ label: `Colour ${walks.find(w => w.name === lifted.name)?.label ?? lifted.name}`,
3049
+ subMenu: [
3050
+ { type: 'subHeader' as const, label: 'Colour by' },
3051
+ ...WALK_FIELDS.map(field => ({
3052
+ type: 'radio' as const,
3053
+ label: field.label,
3054
+ checked: lifted.encoding.field === field.value,
3055
+ onClick: () => {
3056
+ self.setWalkColor(lifted.name, {
3057
+ field: field.value,
3058
+ })
3059
+ },
3060
+ })),
3061
+ { type: 'subHeader' as const, label: 'Palette' },
3062
+ ...WALK_SCHEMES.map(scheme => ({
3063
+ type: 'radio' as const,
3064
+ label: scheme.label,
3065
+ checked: lifted.encoding.scheme === scheme.value,
3066
+ onClick: () => {
3067
+ self.setWalkColor(lifted.name, {
3068
+ scheme: scheme.value,
3069
+ })
3070
+ },
3071
+ })),
3072
+ ],
3073
+ })),
3020
3074
  ],
3021
3075
  },
3022
3076
  ]
@@ -1,3 +1,4 @@
1
+ import { layoutModeByValue } from '@jbrowse/bandage-core/layoutModes'
1
2
  import { readConfObject } from '@jbrowse/core/configuration'
2
3
  import { BaseViewModel } from '@jbrowse/core/pluggableElementTypes/models'
3
4
  import { getSession } from '@jbrowse/core/util'
@@ -10,6 +11,7 @@ import {
10
11
  } from '../graphTrackConfig'
11
12
 
12
13
  import type { SubgraphRegion } from '../GetSubgraph'
14
+ import type { LayoutModeValue } from '@jbrowse/bandage-core/layoutModes'
13
15
  import type { AnyConfigurationModel } from '@jbrowse/core/configuration'
14
16
  import type { FileLocation } from '@jbrowse/core/util/types'
15
17
  import type { Instance } from '@jbrowse/mobx-state-tree'
@@ -109,6 +111,9 @@ export default function stateModelFactory() {
109
111
  return self.cutSubgraph(readConfObject(track, 'adapter'), region, {
110
112
  hops: self.subgraphContext,
111
113
  haplotypes: self.subgraphHaplotypes,
114
+ ...(layoutModeByValue(self.chosenLayoutMode).wholeWalks
115
+ ? { snarls: 'overlapping' as const }
116
+ : {}),
112
117
  })
113
118
  }
114
119
  return undefined
@@ -119,6 +124,16 @@ export default function stateModelFactory() {
119
124
  retryLoad() {
120
125
  void self.load()
121
126
  },
127
+ // walk rows measures whole walks, which a GBZ cut only follows when
128
+ // asked, so entering or leaving it cuts the track again
129
+ switchLayout(mode: LayoutModeValue) {
130
+ const from = layoutModeByValue(self.chosenLayoutMode)
131
+ self.setLayoutMode(mode)
132
+ return self.loadedTrackId &&
133
+ layoutModeByValue(mode).wholeWalks !== from.wholeWalks
134
+ ? self.load()
135
+ : self.recomputeLayout()
136
+ },
122
137
  // Here rather than when the rendering backend starts: the canvas only
123
138
  // mounts once there is a graph, so a view whose graph must be fetched
124
139
  // would never fetch it.
@@ -81,6 +81,7 @@ function syntheticGraph(
81
81
  interface Cut {
82
82
  tier: SubgraphTier
83
83
  region: SubgraphRegion
84
+ snarls?: string
84
85
  }
85
86
 
86
87
  const FORCE_LAYOUT = {
@@ -234,7 +235,7 @@ function createEnvironment({ tiered = true, paths = false } = {}) {
234
235
  method: string,
235
236
  args: {
236
237
  region: SubgraphRegion
237
- opts?: { tier?: SubgraphTier }
238
+ opts?: { tier?: SubgraphTier; snarls?: string }
238
239
  signal?: AbortSignal
239
240
  },
240
241
  ) => {
@@ -247,7 +248,11 @@ function createEnvironment({ tiered = true, paths = false } = {}) {
247
248
  if (method !== 'GetSubgraph') {
248
249
  return Promise.reject(new Error(`Unexpected RPC: ${method}`))
249
250
  }
250
- const cut = { tier: args.opts?.tier ?? 'fine', region: args.region }
251
+ const cut = {
252
+ tier: args.opts?.tier ?? 'fine',
253
+ region: args.region,
254
+ snarls: args.opts?.snarls,
255
+ }
251
256
  cuts.push(cut)
252
257
  if (args.signal) {
253
258
  signals.push(args.signal)
@@ -604,6 +609,22 @@ test('walk rows fit their own bars in the track, since a walk can be longer than
604
609
  expect(pane.viewportOwner).toBe('fit')
605
610
  })
606
611
 
612
+ test('walk rows cut every snarl a walk takes past the window, so a walk comes whole', async () => {
613
+ const { pane, cuts } = await shownGraph({ paths: true })
614
+ await pane.switchLayout('force')
615
+ await wait(SETTLE_MS)
616
+ const before = cuts.length
617
+ expect(cuts.at(-1)!.snarls).toBeUndefined()
618
+ await pane.switchLayout('walkrows')
619
+ await wait(SETTLE_MS)
620
+ expect(cuts).toHaveLength(before + 1)
621
+ expect(cuts.at(-1)!.snarls).toBe('overlapping')
622
+ await pane.switchLayout('force')
623
+ await wait(SETTLE_MS)
624
+ expect(cuts).toHaveLength(before + 2)
625
+ expect(cuts.at(-1)!.snarls).toBeUndefined()
626
+ })
627
+
607
628
  test('a launch in the force layout cuts the window alone', async () => {
608
629
  const { view, cuts } = createEnvironment()
609
630
  view.zoomTo(60_000 / WIDTH_PX)
@@ -220,6 +220,9 @@ export function stateModelFactory(configSchema: LinearGraphDisplayConfigModel) {
220
220
  hops: coarse ? 0 : self.subgraphContext,
221
221
  haplotypes: self.chosenHaplotypes,
222
222
  tier: coarse ? 'coarse' : undefined,
223
+ ...(layoutModeByValue(self.chosenLayoutMode).wholeWalks
224
+ ? { snarls: 'overlapping' as const }
225
+ : {}),
223
226
  })
224
227
  },
225
228
  }))
@@ -257,13 +260,15 @@ export function stateModelFactory(configSchema: LinearGraphDisplayConfigModel) {
257
260
  return true
258
261
  },
259
262
  switchLayout(mode: LayoutModeValue) {
260
- const margins = layoutModeByValue(self.chosenLayoutMode).cutMargins
263
+ const from = layoutModeByValue(self.chosenLayoutMode)
261
264
  self.setLayoutMode(mode)
265
+ const to = layoutModeByValue(mode)
262
266
  const seen = self.host ? hostWindow(self.host) : undefined
263
267
  if (
264
268
  seen &&
265
269
  !self.regionTooLarge &&
266
- layoutModeByValue(mode).cutMargins !== margins
270
+ (to.cutMargins !== from.cutMargins ||
271
+ to.wholeWalks !== from.wholeWalks)
267
272
  ) {
268
273
  return self.recutAt(seen)
269
274
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '4.0.19'
1
+ export const version = '4.0.21'