jbrowse-plugin-graphgenomeviewer 6.1.4 → 6.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jbrowse-plugin-graphgenomeviewer",
3
- "version": "6.1.4",
3
+ "version": "6.2.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": {
@@ -26,7 +26,7 @@
26
26
  "private": false,
27
27
  "dependencies": {
28
28
  "@gmod/bgzf-filehandle": "^6.7.0",
29
- "@gmod/gbz-base": "^7.0.0",
29
+ "@gmod/gbz-base": "^7.1.0",
30
30
  "@gmod/tabix": "^3.9.1",
31
31
  "@gmod/tubemap-core": "^0.1.0",
32
32
  "@jbrowse/cigar-utils": "5.0.0-beta.11",
@@ -15,6 +15,7 @@ import Adapter, {
15
15
  PairTargetError,
16
16
  laneAssemblyName,
17
17
  resolveReferenceSample,
18
+ widestWalkGap,
18
19
  } from './GbzBaseSyntenyAdapter.ts'
19
20
  import configSchema from './configSchema.ts'
20
21
  import GbzBaseSyntenyAdapterF from './index.ts'
@@ -518,7 +519,7 @@ test('the adapter type declares that a window of its anchor answers any lane pai
518
519
  pluginManager.createPluggableElements()
519
520
  expect(
520
521
  pluginManager.getAdapterType('GbzBaseSyntenyAdapter').adapterCapabilities,
521
- ).toContain('lanePairsOnAnchor')
522
+ ).toEqual(expect.arrayContaining(['lanePairsOnAnchor', 'lanePairBatches']))
522
523
  })
523
524
 
524
525
  // HG00673#1's insertion is 16 bp longer than HG01361#2's, so that pair holds
@@ -692,6 +693,82 @@ test('a lane pair walks only its two lanes', async () => {
692
693
  cut.mockRestore()
693
694
  })
694
695
 
696
+ test('a batch of lane pairs answers each pair what it answers alone, from one cut', async () => {
697
+ const adapter = anchoredAdapter()
698
+ const pairs = [
699
+ { queryAssemblyName: 'HG01361#2', targetAssemblyName: 'HG02145#2' },
700
+ { queryAssemblyName: 'HG02145#2', targetAssemblyName: 'HG00673#1' },
701
+ { queryAssemblyName: 'HG00673#1', targetAssemblyName: 'HG00438#1' },
702
+ ]
703
+ const alone = await Promise.all(
704
+ pairs.map(async p =>
705
+ (await feats(adapter, insertionWindow, p)).map(f => f.toJSON()),
706
+ ),
707
+ )
708
+ const { db } = await (
709
+ adapter as unknown as {
710
+ graph: () => Promise<{ db: { getSubgraphs: unknown } }>
711
+ }
712
+ ).graph()
713
+ const cut = vi.spyOn(db, 'getSubgraphs')
714
+ const batch = await feats(adapter, insertionWindow, { lanePairs: pairs })
715
+ expect(cut).toHaveBeenCalledTimes(1)
716
+ cut.mockRestore()
717
+ pairs.forEach((p, i) => {
718
+ expect(alone[i]!.length).toBeGreaterThan(0)
719
+ expect(
720
+ batch
721
+ .filter(
722
+ f =>
723
+ f.get('assemblyName') === p.queryAssemblyName &&
724
+ mateOf(f).assemblyName === p.targetAssemblyName,
725
+ )
726
+ .map(f => f.toJSON()),
727
+ ).toEqual(alone[i])
728
+ })
729
+ })
730
+
731
+ test('a kept walk in two pieces is the gap a pair cut widens its context for', () => {
732
+ const name = (sample: string, haplotype: number, contig = 'c') => ({
733
+ sample,
734
+ haplotype,
735
+ contig,
736
+ fragment: 0,
737
+ })
738
+ const subgraph = (
739
+ spans: { name: ReturnType<typeof name>; start: number; end: number }[],
740
+ ) => ({ walkSpans: () => spans }) as unknown as Subgraph
741
+ const kept = new Set(['A#1', 'B#1'])
742
+ expect(
743
+ widestWalkGap(
744
+ [
745
+ subgraph([
746
+ { name: name('A', 1), start: 0, end: 100 },
747
+ { name: name('A', 1), start: 54_100, end: 60_000 },
748
+ { name: name('B', 1), start: 0, end: 500 },
749
+ { name: name('B', 1), start: 2500, end: 3000 },
750
+ { name: name('C', 1), start: 0, end: 10 },
751
+ { name: name('C', 1), start: 90_000, end: 90_010 },
752
+ ]),
753
+ ],
754
+ kept,
755
+ ),
756
+ ).toBe(54_000)
757
+ expect(
758
+ widestWalkGap(
759
+ [
760
+ subgraph([
761
+ { name: name('A', 1), start: 0, end: 100 },
762
+ { name: name('A', 1), start: 500_000, end: 500_100 },
763
+ { name: name('B', 1, 'c1'), start: 0, end: 100 },
764
+ { name: name('B', 1, 'c2'), start: 5000, end: 5100 },
765
+ ]),
766
+ ],
767
+ kept,
768
+ ),
769
+ ).toBe(0)
770
+ })
771
+
695
772
  test('lane pair ids are the same across two fetches of one window', async () => {
696
773
  const adapter = anchoredAdapter()
697
774
  const ids = async () =>
@@ -1,4 +1,4 @@
1
- import { GBZBase } from '@gmod/gbz-base'
1
+ import { GBZBase, SubgraphLimitError } from '@gmod/gbz-base'
2
2
  import {
3
3
  NodeLimitError,
4
4
  cutWindowGFA,
@@ -34,6 +34,7 @@ import type {
34
34
  PairAlignment,
35
35
  PathName,
36
36
  PathQuery,
37
+ Subgraph,
37
38
  } from '@gmod/gbz-base'
38
39
  import type { BaseOptions } from '@jbrowse/core/data_adapters/BaseAdapter'
39
40
  import type { Feature, SimpleFeatureSerialized } from '@jbrowse/core/util'
@@ -91,6 +92,16 @@ export interface GbzHeaderLane {
91
92
  export interface GbzFeatureOptions extends ComparativeOptions {
92
93
  haplotypes?: string[]
93
94
  queryAssemblyName?: string
95
+ lanePairs?: LanePair[]
96
+ }
97
+
98
+ /**
99
+ * Two lanes aligned to each other inside an anchor window: records on the
100
+ * query lane's contigs, with the target lane's walk as the mate
101
+ */
102
+ export interface LanePair {
103
+ queryAssemblyName: string
104
+ targetAssemblyName: string
94
105
  }
95
106
 
96
107
  export class PairTargetError extends Error {
@@ -103,6 +114,16 @@ export class PairTargetError extends Error {
103
114
  }
104
115
  }
105
116
 
117
+ function onePair(
118
+ queryAssemblyName: string,
119
+ targetAssemblyName: string | undefined,
120
+ ): LanePair {
121
+ if (targetAssemblyName === undefined) {
122
+ throw new PairTargetError(queryAssemblyName)
123
+ }
124
+ return { queryAssemblyName, targetAssemblyName }
125
+ }
126
+
106
127
  export class HaplotypeWindowError extends Error {
107
128
  override name = 'HaplotypeWindowError'
108
129
 
@@ -213,6 +234,39 @@ export function pairFeature({
213
234
  })
214
235
  }
215
236
 
237
+ // The longest detour a pair cut widens its context to take in
238
+ const BRIDGE_MAX_BP = 120_000
239
+
240
+ /**
241
+ * The widest gap, up to `BRIDGE_MAX_BP`, between two pieces of one kept walk:
242
+ * a haplotype that left the cut's nodes for sequence the reference lacks and
243
+ * came back. Two kept haplotypes that share that sequence only align across it
244
+ * once its nodes are in the cut.
245
+ */
246
+ export function widestWalkGap(subgraphs: Subgraph[], kept: Set<string>) {
247
+ const byPath = new Map<string, [number, number][]>()
248
+ for (const subgraph of subgraphs) {
249
+ for (const { name, start, end } of subgraph.walkSpans()) {
250
+ const prefix = haplotypePrefix(name)
251
+ if (kept.has(prefix)) {
252
+ const key = `${prefix}#${name.contig}`
253
+ byPath.set(key, [...(byPath.get(key) ?? []), [start, end]])
254
+ }
255
+ }
256
+ }
257
+ let widest = 0
258
+ for (const spans of byPath.values()) {
259
+ spans.sort((a, b) => a[0] - b[0])
260
+ for (let i = 1; i < spans.length; i++) {
261
+ const gap = spans[i]![0] - spans[i - 1]![1]
262
+ if (gap > 0 && gap <= BRIDGE_MAX_BP) {
263
+ widest = Math.max(widest, gap)
264
+ }
265
+ }
266
+ }
267
+ return widest
268
+ }
269
+
216
270
  // A window past the node limit fails as a zoom-in notice naming a span that
217
271
  // fits
218
272
  function zoomInNotice(error: unknown, what: string) {
@@ -495,71 +549,93 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
495
549
  }
496
550
 
497
551
  /**
498
- * The two lanes' walks cut out of the anchor window alone, and each walk
499
- * of the query lane aligned to each walk of the target lane on the nodes
500
- * both visit, with no base compared. The records sit on the query lane's
501
- * contigs, the lane the display draws on top.
552
+ * Each pair's two lanes cut out of the anchor window alone, all pairs in one
553
+ * cut, and each walk of a pair's query lane aligned to each walk of its
554
+ * target lane on the nodes both visit, with no base compared. A pair's
555
+ * records sit on its query lane's contigs, the lane the display draws on top.
502
556
  *
503
557
  * Whether the index has anchor rows changes only how fast gbz-base finds
504
558
  * the walks: both routes cut the same pieces.
505
559
  */
506
560
  private async pairFeatures(
507
561
  region: Region,
508
- queryAssemblyName: string,
562
+ pairs: LanePair[],
509
563
  opts: GbzFeatureOptions,
510
564
  ) {
511
- const { targetAssemblyName } = opts
512
- if (targetAssemblyName === undefined) {
513
- throw new PairTargetError(queryAssemblyName)
514
- }
515
565
  const { db } = await this.graph(opts)
516
566
  const { refName, start, end } = region
517
- const featureSide = await this.laneHaplotypes(queryAssemblyName, opts)
518
- const mateSide = await this.laneHaplotypes(targetAssemblyName, opts)
519
- const kept = [...featureSide, ...mateSide]
567
+ const sides = await Promise.all(
568
+ pairs.map(async pair => ({
569
+ pair,
570
+ featureSide: await this.laneHaplotypes(pair.queryAssemblyName, opts),
571
+ mateSide: await this.laneHaplotypes(pair.targetAssemblyName, opts),
572
+ })),
573
+ )
574
+ const answerable = sides.filter(
575
+ ({ featureSide, mateSide }) =>
576
+ featureSide.length > 0 && mateSide.length > 0,
577
+ )
578
+ const kept = new Set(
579
+ answerable.flatMap(({ featureSide, mateSide }) =>
580
+ [...featureSide, ...mateSide].map(haplotypePrefix),
581
+ ),
582
+ )
520
583
  const query = await this.referenceQuery(refName, opts)
521
584
  const nodeLimit: number = this.getConf('nodeLimit')
522
- const cut = (path: PathQuery) =>
523
- db
524
- .getSubgraphs({
525
- path,
526
- start,
527
- end,
528
- context: this.getConf('context'),
529
- snarls: this.getConf('subgraphSnarls'),
530
- haplotypes: 'all',
531
- limit: nodeLimit,
532
- signal: opts.signal,
533
- keep: name =>
534
- kept.some(
535
- haplotype =>
536
- haplotype.sample === name.sample &&
537
- haplotype.haplotype === name.haplotype,
538
- ),
539
- })
540
- .catch((error: unknown) => {
541
- throw nodeLimitError(error, nodeLimit, end - start) ?? error
542
- })
585
+ const baseContext: number = this.getConf('context')
586
+ const [first] = pairs
587
+ const cut = (path: PathQuery, context: number) =>
588
+ db.getSubgraphs({
589
+ path,
590
+ start,
591
+ end,
592
+ context,
593
+ snarls: this.getConf('subgraphSnarls'),
594
+ haplotypes: 'all',
595
+ limit: nodeLimit,
596
+ signal: opts.signal,
597
+ keep: name => kept.has(haplotypePrefix(name)),
598
+ })
599
+ // a cut past the node limit for the wider context keeps the first one
600
+ const bridged = async (path: PathQuery) => {
601
+ const subgraphs = await cut(path, baseContext).catch((error: unknown) => {
602
+ throw nodeLimitError(error, nodeLimit, end - start) ?? error
603
+ })
604
+ const gap = widestWalkGap(subgraphs, kept)
605
+ const context = Math.ceil(gap / 2) + 2000
606
+ return gap > 0 && context > baseContext
607
+ ? cut(path, context).catch((error: unknown) => {
608
+ if (error instanceof SubgraphLimitError) {
609
+ return subgraphs
610
+ }
611
+ throw error
612
+ })
613
+ : subgraphs
614
+ }
543
615
  const subgraphs =
544
- query && featureSide.length > 0 && mateSide.length > 0
616
+ query && first && answerable.length > 0
545
617
  ? await updateStatus(
546
- `Reading ${queryAssemblyName} against ${targetAssemblyName}`,
618
+ pairs.length === 1
619
+ ? `Reading ${first.queryAssemblyName} against ${first.targetAssemblyName}`
620
+ : `Reading ${pairs.length} lane pairs`,
547
621
  opts.statusCallback,
548
- () => cut(query),
622
+ () => bridged(query),
549
623
  )
550
624
  : []
551
625
  return subgraphs.flatMap(subgraph =>
552
- featureSide.flatMap(target =>
553
- mateSide.flatMap(mate =>
554
- subgraph
555
- .pairAlignments({ target, query: mate, bases: false })
556
- .map(pair =>
557
- pairFeature({
558
- pair,
559
- lane: queryAssemblyName,
560
- mateLane: targetAssemblyName,
561
- }),
562
- ),
626
+ answerable.flatMap(({ pair, featureSide, mateSide }) =>
627
+ featureSide.flatMap(target =>
628
+ mateSide.flatMap(mate =>
629
+ subgraph
630
+ .pairAlignments({ target, query: mate, bases: false })
631
+ .map(alignment =>
632
+ pairFeature({
633
+ pair: alignment,
634
+ lane: pair.queryAssemblyName,
635
+ mateLane: pair.targetAssemblyName,
636
+ }),
637
+ ),
638
+ ),
563
639
  ),
564
640
  ),
565
641
  )
@@ -574,10 +650,15 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
574
650
  // the graph is indexed on its reference alone, so a window on a
575
651
  // haplotype lane has no answer: a lane pair is read inside the anchor's
576
652
  if (region.assemblyName === anchor) {
577
- const features =
578
- opts.queryAssemblyName === undefined
653
+ const features = opts.lanePairs?.length
654
+ ? await this.pairFeatures(region, opts.lanePairs, opts)
655
+ : opts.queryAssemblyName === undefined
579
656
  ? await this.anchorFeatures(region, opts)
580
- : await this.pairFeatures(region, opts.queryAssemblyName, opts)
657
+ : await this.pairFeatures(
658
+ region,
659
+ [onePair(opts.queryAssemblyName, opts.targetAssemblyName)],
660
+ opts,
661
+ )
581
662
  for (const feature of features) {
582
663
  observer.next(feature)
583
664
  }
@@ -17,11 +17,13 @@ export default function GbzBaseSyntenyAdapterF(pluginManager: PluginManager) {
17
17
  // adapter and offer its lane picker over the whole graph.
18
18
  // lanePairsOnAnchor: a window of the anchor answers any two lanes'
19
19
  // alignment to each other, so the display fetches each adjacent pair
20
- // rather than composing it through the reference
20
+ // rather than composing it through the reference.
21
+ // lanePairBatches: every pair on a window comes from one cut
21
22
  adapterCapabilities: [
22
23
  'getSubgraph',
23
24
  'headerLanes',
24
25
  'lanePairsOnAnchor',
26
+ 'lanePairBatches',
25
27
  ],
26
28
  adapterMetadata: {
27
29
  category: 'Synteny adapters',
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '6.1.4'
1
+ export const version = '6.2.0'