jbrowse-plugin-graphgenomeviewer 3.0.4 → 3.1.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 (58) hide show
  1. package/README.md +29 -2
  2. package/dist/chunks/GbzBaseSyntenyAdapter-MIOUYHOF.js +8 -0
  3. package/dist/chunks/GbzBaseSyntenyAdapter-MIOUYHOF.js.map +7 -0
  4. package/dist/chunks/GraphGenomeView-TM7WYDLH.js +14 -0
  5. package/dist/chunks/GraphGenomeView-TM7WYDLH.js.map +7 -0
  6. package/dist/chunks/MinigraphBubbleAdapter-A7SBF2XY.js +2 -0
  7. package/dist/chunks/MinigraphBubbleAdapter-A7SBF2XY.js.map +7 -0
  8. package/dist/chunks/RgfaTabixAdapter-7ZGKJJYW.js +3 -0
  9. package/dist/chunks/RgfaTabixAdapter-7ZGKJJYW.js.map +7 -0
  10. package/dist/chunks/{chunk-X3X74OGB.js → chunk-7T2HTRP3.js} +2 -2
  11. package/dist/chunks/{chunk-X3X74OGB.js.map → chunk-7T2HTRP3.js.map} +3 -3
  12. package/dist/chunks/{chunk-JCG7RLMB.js → chunk-HOM2Y6XS.js} +2 -2
  13. package/dist/chunks/chunk-HOM2Y6XS.js.map +7 -0
  14. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  15. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  16. package/package.json +21 -22
  17. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.test.ts +69 -0
  18. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.ts +13 -10
  19. package/src/GetSubgraph.ts +5 -1
  20. package/src/GraphGenomeView/components/FollowControl.test.tsx +69 -0
  21. package/src/GraphGenomeView/components/FollowControl.tsx +42 -0
  22. package/src/GraphGenomeView/components/GraphStats.tsx +1 -0
  23. package/src/GraphGenomeView/components/GraphToolbar.tsx +2 -0
  24. package/src/GraphGenomeView/follow.test.ts +624 -0
  25. package/src/GraphGenomeView/follow.ts +129 -0
  26. package/src/GraphGenomeView/layout/sampleRowLayout.test.ts +16 -0
  27. package/src/GraphGenomeView/layout/sampleRowLayout.ts +13 -4
  28. package/src/GraphGenomeView/layoutModes.ts +3 -1
  29. package/src/GraphGenomeView/model.ts +361 -55
  30. package/src/GraphGenomeView/types.ts +4 -0
  31. package/src/MinigraphBubbleAdapter/MinigraphBubbleAdapter.ts +1 -1
  32. package/src/RgfaTabixAdapter/RgfaTabixAdapter.test.ts +73 -0
  33. package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +48 -16
  34. package/src/RgfaTabixAdapter/configSchema.ts +70 -3
  35. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.links.bed.gz +0 -0
  36. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.links.bed.gz.tbi +0 -0
  37. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.segs.bed.gz +0 -0
  38. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.segs.bed.gz.tbi +0 -0
  39. package/src/launchSubgraph/index.ts +12 -6
  40. package/src/launchSubgraph/launchSubgraph.test.ts +89 -3
  41. package/src/launchSubgraph/launchSubgraphView.ts +14 -6
  42. package/src/launchSubgraph/linearViewMenuItems.ts +3 -0
  43. package/src/launchSubgraph/subgraphMenuItems.ts +7 -0
  44. package/src/launchSubgraph/testEnv.ts +2 -0
  45. package/src/panSNTabix.ts +6 -5
  46. package/src/synteny/ComparativeAdapterBase.ts +13 -10
  47. package/src/synteny/clipFeatureToRegion.test.ts +41 -1
  48. package/src/synteny/clipFeatureToRegion.ts +22 -5
  49. package/src/version.ts +1 -1
  50. package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js +0 -8
  51. package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js.map +0 -7
  52. package/dist/chunks/GraphGenomeView-UD5WCV3Q.js +0 -14
  53. package/dist/chunks/GraphGenomeView-UD5WCV3Q.js.map +0 -7
  54. package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js +0 -2
  55. package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js.map +0 -7
  56. package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js +0 -3
  57. package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js.map +0 -7
  58. package/dist/chunks/chunk-JCG7RLMB.js.map +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "3.0.4",
3
+ "version": "3.1.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": {
@@ -25,12 +25,12 @@
25
25
  ],
26
26
  "private": false,
27
27
  "dependencies": {
28
- "@gmod/gbz-base": "^2.7.0",
29
- "@gmod/tabix": "^3.8.2",
30
- "@jbrowse/cigar-utils": "^5.0.0-beta.9",
31
- "@jbrowse/core": "^5.0.0-beta.9",
32
- "@jbrowse/render-core": "^5.0.0-beta.9",
33
- "@jbrowse/synteny-core": "^5.0.0-beta.9"
28
+ "@gmod/gbz-base": "^2.8.0",
29
+ "@gmod/tabix": "^3.8.3",
30
+ "@jbrowse/cigar-utils": "5.0.0-beta.9",
31
+ "@jbrowse/core": "5.0.0-beta.9",
32
+ "@jbrowse/render-core": "5.0.0-beta.9",
33
+ "@jbrowse/synteny-core": "5.0.0-beta.9"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@emotion/react": "^11.14.0",
@@ -38,30 +38,30 @@
38
38
  "@eslint/js": "^10.0.1",
39
39
  "@fal-works/esbuild-plugin-global-externals": "^2.1.2",
40
40
  "@jbrowse/cli": "^4.3.0",
41
- "@jbrowse/mobx-state-tree": "^6.5.2",
42
- "@jbrowse/plugin-canvas": "^5.0.0-beta.9",
43
- "@jbrowse/plugin-linear-genome-view": "^5.0.0-beta.9",
41
+ "@jbrowse/mobx-state-tree": "^6.6.1",
42
+ "@jbrowse/plugin-canvas": "5.0.0-beta.9",
43
+ "@jbrowse/plugin-linear-genome-view": "5.0.0-beta.9",
44
44
  "@mui/icons-material": "^9.4.0",
45
45
  "@mui/material": "^9.4.0",
46
46
  "@mui/system": "^9.4.0",
47
47
  "@testing-library/react": "^16.3.3",
48
- "@types/node": "^26.5.1",
48
+ "@types/node": "^26.6.2",
49
49
  "@types/react": "^19.3.0",
50
50
  "@types/react-dom": "^19.3.0",
51
- "@typescript-eslint/eslint-plugin": "^8.70.0",
52
- "@typescript-eslint/parser": "^8.70.0",
51
+ "@typescript-eslint/eslint-plugin": "^8.70.1",
52
+ "@typescript-eslint/parser": "^8.70.1",
53
53
  "esbuild": "^0.28.2",
54
- "eslint": "^10.10.0",
54
+ "eslint": "^10.11.0",
55
55
  "eslint-plugin-import-x": "^4.17.1",
56
56
  "eslint-plugin-react": "^7.37.5",
57
57
  "eslint-plugin-react-hooks": "^7.1.1",
58
- "git-cliff": "^2.13.1",
59
- "jsdom": "^30.0.1",
60
- "mobx": "^7.0.3",
58
+ "git-cliff": "^2.14.2",
59
+ "jsdom": "^30.1.1",
60
+ "mobx": "^7.0.4",
61
61
  "mobx-react": "^10.0.2",
62
- "prettier": "^3.9.6",
62
+ "prettier": "^3.9.9",
63
63
  "pretty-bytes": "^7.1.3",
64
- "puppeteer": "^25.10.0",
64
+ "puppeteer": "^25.12.0",
65
65
  "react": "19.3.0",
66
66
  "react-dom": "19.3.0",
67
67
  "rimraf": "^6.1.3",
@@ -69,9 +69,9 @@
69
69
  "serve": "^14.2.6",
70
70
  "tss-react": "^4.9.21",
71
71
  "typescript": "^6.0.3",
72
- "typescript-eslint": "^8.70.0",
72
+ "typescript-eslint": "^8.70.1",
73
73
  "typescript7": "npm:typescript@^7.0.2",
74
- "vitest": "^5.0.0"
74
+ "vitest": "^5.0.2"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": ">=18.0.0",
@@ -93,7 +93,6 @@
93
93
  "bench:layout": "node scripts/bench-layout.mjs",
94
94
  "layout-digest": "node scripts/layout-digest.mjs",
95
95
  "build:bundle": "NODE_ENV=production node esbuild.mjs",
96
- "betabuild": "bash scripts/betabuild.sh",
97
96
  "typecheck": "node node_modules/typescript7/bin/tsc --noEmit",
98
97
  "host-compat": "node scripts/host-compat-probe.mjs",
99
98
  "lint": "eslint --report-unused-disable-directives --max-warnings 0",
@@ -1,3 +1,6 @@
1
+ import { Subgraph } from '@gmod/gbz-base'
2
+ import { numericCigarToString } from '@jbrowse/cigar-utils'
3
+ import PluginManager from '@jbrowse/core/PluginManager'
1
4
  import { firstValueFrom } from 'rxjs'
2
5
  import { toArray } from 'rxjs/operators'
3
6
 
@@ -7,6 +10,7 @@ import Adapter, {
7
10
  laneAssemblyName,
8
11
  } from './GbzBaseSyntenyAdapter.ts'
9
12
  import configSchema from './configSchema.ts'
13
+ import GbzBaseSyntenyAdapterF from './index.ts'
10
14
 
11
15
  import type { SyntenyMate } from '@jbrowse/synteny-core'
12
16
 
@@ -459,6 +463,15 @@ const spanOf = (intervals: { start: number; end: number }[]) => ({
459
463
  end: Math.max(...intervals.map(i => i.end)),
460
464
  })
461
465
 
466
+ test('the adapter type declares that a window of its anchor answers any lane pair', () => {
467
+ const pluginManager = new PluginManager()
468
+ GbzBaseSyntenyAdapterF(pluginManager)
469
+ pluginManager.createPluggableElements()
470
+ expect(
471
+ pluginManager.getAdapterType('GbzBaseSyntenyAdapter').adapterCapabilities,
472
+ ).toContain('lanePairsOnAnchor')
473
+ })
474
+
462
475
  // HG00673#1's insertion is 16 bp longer than HG01361#2's, so that pair holds
463
476
  // an indel, which pins which side of the record its CIGAR walks. HG00438#1 is
464
477
  // assembled reverse to GRCh38. Each pair covers the stretch of each haplotype
@@ -476,6 +489,7 @@ test.each(['HG02145#2', 'HG00673#1', 'HG00438#1'])(
476
489
  expect(f.get('assemblyName')).toBe('HG01361#2')
477
490
  const mate = mateOf(f)
478
491
  expect(mate.assemblyName).toBe(lower)
492
+ expect(f.get('CIGAR')).toMatch(/^(\d+[=ID])+$/)
479
493
  expect(cigarSpans(f.get('CIGAR'))).toEqual({
480
494
  feature: f.get('end') - f.get('start'),
481
495
  mate: mate.end - mate.start,
@@ -520,6 +534,61 @@ test('a reverse-strand pair writes its CIGAR along the query lane', async () =>
520
534
  expect(reverse.get('CIGAR')).toBe(forward.get('CIGAR'))
521
535
  })
522
536
 
537
+ // HG01361#2 and HG00673#1 differ by one SNP in this window, which the graph
538
+ // holds as a bubble: an insertion and a deletion, where comparing the bases
539
+ // would write 1X
540
+ const snpPair = {
541
+ queryAssemblyName: 'HG01361#2',
542
+ targetAssemblyName: 'HG00673#1',
543
+ }
544
+
545
+ test('a SNP between two lanes is the bubble the graph holds, an insertion and a deletion', async () => {
546
+ const records = await feats(anchoredAdapter(), window, snpPair)
547
+ expect(records.map(f => [f.get('CIGAR'), f.get('numMatches')])).toEqual([
548
+ ['800=1I1D413=', 1213],
549
+ ])
550
+ })
551
+
552
+ test('a lane pair fetched the way MultiWaySyntenyDisplay fetches it hands over those ops', async () => {
553
+ const records = await firstValueFrom(
554
+ anchoredAdapter()
555
+ .getFeaturesInMultipleRegions([window] as never, {
556
+ ...snpPair,
557
+ clipToRegion: true,
558
+ splitAtGapBp: 10_000,
559
+ keepAlignment: true,
560
+ })
561
+ .pipe(toArray()),
562
+ )
563
+ expect(
564
+ records.map(f =>
565
+ numericCigarToString(f.get('alignmentOps') as Uint32Array),
566
+ ),
567
+ ).toEqual(['800=1I1D413='])
568
+ })
569
+
570
+ // HG00673#2 differs from HG01361#2 in a 4 bp bubble whose middle two bases
571
+ // agree, which comparing bases would count as matches. Nowhere in the slice
572
+ // would it find an inversion inside a gap, so the option alone keeps one out
573
+ test('a lane pair asks gbz-base to compare no base, and every match it answers is a shared node', async () => {
574
+ const read = vi.spyOn(Subgraph.prototype, 'pairAlignments')
575
+ await feats(anchoredAdapter(), window, {
576
+ queryAssemblyName: 'HG01361#2',
577
+ targetAssemblyName: 'HG00673#2',
578
+ })
579
+ const answered = read.mock.results.flatMap(result =>
580
+ result.type === 'return' ? result.value : [],
581
+ )
582
+ expect(answered.length).toBeGreaterThan(0)
583
+ for (const [opts] of read.mock.calls) {
584
+ expect(opts.bases).toBe(false)
585
+ }
586
+ for (const pair of answered) {
587
+ expect(pair.matches).toBe(pair.sharedBases)
588
+ }
589
+ read.mockRestore()
590
+ })
591
+
523
592
  test('the pair with an indel between its lanes writes it', async () => {
524
593
  const records = await feats(anchoredAdapter(), insertionWindow, {
525
594
  queryAssemblyName: 'HG01361#2',
@@ -546,8 +546,9 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
546
546
 
547
547
  /**
548
548
  * The two lanes' walks cut out of the anchor window alone, and each walk
549
- * of the query lane aligned to each walk of the target lane. The records
550
- * sit on the query lane's contigs, the lane the display draws on top.
549
+ * of the query lane aligned to each walk of the target lane on the nodes
550
+ * both visit, with no base compared. The records sit on the query lane's
551
+ * contigs, the lane the display draws on top.
551
552
  *
552
553
  * Only a cut walked from the haplotype index's anchor rows holds each walk
553
554
  * whole. The sampled cut leaves a walk in pieces wherever it strays past the
@@ -593,7 +594,7 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
593
594
  query && anchored && featureSide.length > 0 && mateSide.length > 0
594
595
  ? (
595
596
  await updateStatus(
596
- `Aligning ${queryAssemblyName} to ${targetAssemblyName}`,
597
+ `Reading ${queryAssemblyName} against ${targetAssemblyName}`,
597
598
  opts.statusCallback,
598
599
  async () =>
599
600
  Promise.all(
@@ -612,13 +613,15 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
612
613
  ? subgraphs.flatMap(subgraph =>
613
614
  featureSide.flatMap(target =>
614
615
  mateSide.flatMap(mate =>
615
- subgraph.pairAlignments({ target, query: mate }).map(pair =>
616
- pairFeature({
617
- pair,
618
- lane: queryAssemblyName,
619
- mateLane: targetAssemblyName,
620
- }),
621
- ),
616
+ subgraph
617
+ .pairAlignments({ target, query: mate, bases: false })
618
+ .map(pair =>
619
+ pairFeature({
620
+ pair,
621
+ lane: queryAssemblyName,
622
+ mateLane: targetAssemblyName,
623
+ }),
624
+ ),
622
625
  ),
623
626
  ),
624
627
  )
@@ -12,10 +12,14 @@ import type { Region } from '@jbrowse/core/util'
12
12
  // cut reads; the GBZ cut has its own bp `context` slot. `haplotypes` is the
13
13
  // set the cut is for, lane assembly names or PanSN prefixes, which only the GBZ
14
14
  // cut reads: it keeps those walks and the nodes they visit. Undefined or empty
15
- // is every haplotype.
15
+ // is every haplotype. `tier` picks which of an rGFA track's two index pairs the
16
+ // cut reads, the adapter's own or its `coarse` one; fine when absent.
17
+ export type SubgraphTier = 'fine' | 'coarse'
18
+
16
19
  export interface SubgraphCutOptions {
17
20
  hops?: number
18
21
  haplotypes?: string[]
22
+ tier?: SubgraphTier
19
23
  }
20
24
 
21
25
  // What an adapter's cut is handed: the payload, plus the call's own signal so
@@ -0,0 +1,69 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+
3
+ import FollowControl from './FollowControl'
4
+
5
+ import type { GraphGenomeViewModel } from '../model'
6
+
7
+ function control(state: Partial<GraphGenomeViewModel>) {
8
+ const model = {
9
+ loadedTrackId: 'graph',
10
+ followLinearView: true,
11
+ followState: { active: true },
12
+ followNote: undefined,
13
+ cutTier: 'fine',
14
+ setFollowLinearView: vi.fn(),
15
+ ...state,
16
+ }
17
+ render(<FollowControl model={model as unknown as GraphGenomeViewModel} />)
18
+ return model
19
+ }
20
+
21
+ function status() {
22
+ return screen.queryByTestId('graph-follow-status')?.textContent
23
+ }
24
+
25
+ test('a following graph offers Pin, and says it is following', () => {
26
+ const model = control({})
27
+ expect(status()).toBe('Following the linear view')
28
+ fireEvent.click(screen.getByText('Pin'))
29
+ expect(model.setFollowLinearView).toHaveBeenCalledWith(false)
30
+ })
31
+
32
+ test('a pinned graph offers Follow, and says it is pinned', () => {
33
+ const model = control({
34
+ followLinearView: false,
35
+ followState: { active: false, reason: undefined },
36
+ })
37
+ expect(status()).toBe('Pinned')
38
+ fireEvent.click(screen.getByText('Follow'))
39
+ expect(model.setFollowLinearView).toHaveBeenCalledWith(true)
40
+ })
41
+
42
+ test('a follow that cannot run says why', () => {
43
+ control({
44
+ followState: {
45
+ active: false,
46
+ reason: 'Not following: x is not reference bp (Force-directed layout)',
47
+ },
48
+ })
49
+ expect(status()).toMatch(/Force-directed layout/)
50
+ expect(screen.getByText('Pin')).toBeTruthy()
51
+ })
52
+
53
+ test('a coarse cut says so', () => {
54
+ control({ cutTier: 'coarse' })
55
+ expect(status()).toBe('Following the linear view, coarse tier')
56
+ })
57
+
58
+ test('a held cut says what it is holding', () => {
59
+ control({
60
+ followNote: 'Holding the last cut: 6 Mb is past the 5 Mb a cut may span',
61
+ })
62
+ expect(status()).toMatch(/^Holding the last cut/)
63
+ })
64
+
65
+ test('a graph from a whole file has no follow control', () => {
66
+ control({ loadedTrackId: '' })
67
+ expect(screen.queryByText('Pin')).toBeNull()
68
+ expect(status()).toBeUndefined()
69
+ })
@@ -0,0 +1,42 @@
1
+ import { Button, Typography } from '@mui/material'
2
+ import { observer } from 'mobx-react'
3
+
4
+ import type { GraphGenomeViewModel } from '../model'
5
+
6
+ const FollowControl = observer(function FollowControl({
7
+ model,
8
+ }: {
9
+ model: GraphGenomeViewModel
10
+ }) {
11
+ const { followLinearView, followState } = model
12
+ const note = followState.active ? model.followNote : undefined
13
+ const state = !followLinearView
14
+ ? 'Pinned'
15
+ : followState.active
16
+ ? (note ??
17
+ `Following the linear view${model.cutTier === 'coarse' ? ', coarse tier' : ''}`)
18
+ : followState.reason
19
+ return model.loadedTrackId ? (
20
+ <>
21
+ <Button
22
+ size="small"
23
+ onClick={() => {
24
+ model.setFollowLinearView(!followLinearView)
25
+ }}
26
+ >
27
+ {followLinearView ? 'Pin' : 'Follow'}
28
+ </Button>
29
+ {state ? (
30
+ <Typography
31
+ variant="caption"
32
+ color={note ? 'warning.main' : 'text.secondary'}
33
+ data-testid="graph-follow-status"
34
+ >
35
+ {state}
36
+ </Typography>
37
+ ) : null}
38
+ </>
39
+ ) : null
40
+ })
41
+
42
+ export default FollowControl
@@ -69,6 +69,7 @@ const GraphStats = observer(function GraphStats({
69
69
  data-layout-ms={model.lastLayoutMs ?? ''}
70
70
  data-geometry-ms={model.lastGeometryMs ?? ''}
71
71
  data-geometry-strokes={model.lastGeometryStrokeCount ?? ''}
72
+ data-follow-recuts={model.followRecuts}
72
73
  >
73
74
  <Typography variant="body2" component="span">
74
75
  {model.nodeCount} nodes, {model.edgeCount} edges
@@ -6,6 +6,7 @@ import { observer } from 'mobx-react'
6
6
  import { makeStyles } from 'tss-react/mui'
7
7
 
8
8
  import ColorSchemeSelect from './ColorSchemeSelect'
9
+ import FollowControl from './FollowControl'
9
10
  import GraphStats from './GraphStats'
10
11
  import LayoutSelect from './LayoutSelect'
11
12
  import RepeatSelect from './RepeatSelect'
@@ -75,6 +76,7 @@ const GraphToolbar = observer(function GraphToolbar({
75
76
  </IconButton>
76
77
  </Tooltip>
77
78
  <ZoomDisplay model={model} />
79
+ <FollowControl model={model} />
78
80
  <GraphStats model={model} />
79
81
  <SettingsMenu model={model} />
80
82
  </div>