jbrowse-plugin-graphgenomeviewer 3.0.4 → 3.0.5

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": "3.0.4",
3
+ "version": "3.0.5",
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": {
@@ -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",
@@ -3,7 +3,6 @@ import { toArray } from 'rxjs/operators'
3
3
 
4
4
  import Adapter, {
5
5
  NoReferenceSampleError,
6
- PairTargetError,
7
6
  laneAssemblyName,
8
7
  } from './GbzBaseSyntenyAdapter.ts'
9
8
  import configSchema from './configSchema.ts'
@@ -421,265 +420,6 @@ test('getSubgraph refuses a window on a haplotype lane with a message naming the
421
420
  ).rejects.toThrow(/cut on its reference, hg38; a window on HG01106#1/)
422
421
  })
423
422
 
424
- // the bases each side of a record walks, in JBrowse's convention: the feature
425
- // side takes D, the mate side I
426
- function cigarSpans(cigar: string) {
427
- let feature = 0
428
- let mate = 0
429
- for (const [, n, op] of cigar.matchAll(/(\d+)([MIDX=])/g)) {
430
- feature += op === 'I' ? 0 : +n
431
- mate += op === 'D' ? 0 : +n
432
- }
433
- return { feature, mate }
434
- }
435
-
436
- const largestGap = (cigar: string) =>
437
- Math.max(0, ...[...cigar.matchAll(/(\d+)[ID]/g)].map(([, n]) => Number(n)))
438
-
439
- // HG01361#2 and HG02145#2 each carry a ~170 bp insertion against GRCh38 at
440
- // 31,498,602, which a band composed through GRCh38 draws as nothing
441
- const insertionWindow = { ...window, start: 31498400, end: 31498900 }
442
- const pair = {
443
- queryAssemblyName: 'HG01361#2',
444
- targetAssemblyName: 'HG02145#2',
445
- }
446
-
447
- // the companion carries anchor rows, the one cut that holds a walk whole
448
- const anchoredAdapter = (conf: Record<string, unknown> = {}) =>
449
- makeAdapter({
450
- haplotypeIndexLocation: {
451
- localPath: require.resolve('./test_data/micb-kir3dl1.haplotype-index.db'),
452
- locationType: 'LocalPathLocation',
453
- },
454
- ...conf,
455
- })
456
-
457
- const spanOf = (intervals: { start: number; end: number }[]) => ({
458
- start: Math.min(...intervals.map(i => i.start)),
459
- end: Math.max(...intervals.map(i => i.end)),
460
- })
461
-
462
- // HG00673#1's insertion is 16 bp longer than HG01361#2's, so that pair holds
463
- // an indel, which pins which side of the record its CIGAR walks. HG00438#1 is
464
- // assembled reverse to GRCh38. Each pair covers the stretch of each haplotype
465
- // that its own alignment to GRCh38 does, which pairAlignments never reads
466
- test.each(['HG02145#2', 'HG00673#1', 'HG00438#1'])(
467
- 'a lane pair on an anchor window answers HG01361#2 aligned to %s, on HG01361#2',
468
- async lower => {
469
- const adapter = anchoredAdapter()
470
- const records = await feats(adapter, insertionWindow, {
471
- queryAssemblyName: 'HG01361#2',
472
- targetAssemblyName: lower,
473
- })
474
- expect(records.length).toBeGreaterThan(0)
475
- for (const f of records) {
476
- expect(f.get('assemblyName')).toBe('HG01361#2')
477
- const mate = mateOf(f)
478
- expect(mate.assemblyName).toBe(lower)
479
- expect(cigarSpans(f.get('CIGAR'))).toEqual({
480
- feature: f.get('end') - f.get('start'),
481
- mate: mate.end - mate.start,
482
- })
483
- }
484
- const onReference = async (lane: string) =>
485
- (await feats(adapter, insertionWindow, { targetAssemblyName: lane })).map(
486
- f => mateOf(f),
487
- )
488
- const [upper] = await onReference('HG01361#2')
489
- const [mate] = await onReference(lower)
490
- expect(new Set(records.map(f => f.get('refName')))).toEqual(
491
- new Set([upper!.refName]),
492
- )
493
- expect(
494
- spanOf(records.map(f => ({ start: f.get('start'), end: f.get('end') }))),
495
- ).toEqual(spanOf([upper!]))
496
- expect(new Set(records.map(f => mateOf(f).refName))).toEqual(
497
- new Set([mate!.refName]),
498
- )
499
- expect(spanOf(records.map(f => mateOf(f)))).toEqual(spanOf([mate!]))
500
- },
501
- )
502
-
503
- // the same 179 bp allele assembled forward (HG01928#2) and reverse
504
- // (HG00438#1): a `-` record's CIGAR reads along the query lane, as a `+`
505
- // record's does, so the one difference from HG01361#2 sits at the same place
506
- test('a reverse-strand pair writes its CIGAR along the query lane', async () => {
507
- const adapter = anchoredAdapter()
508
- const pairWith = async (lower: string) =>
509
- (
510
- await feats(adapter, insertionWindow, {
511
- queryAssemblyName: 'HG01361#2',
512
- targetAssemblyName: lower,
513
- })
514
- )[0]!
515
- const reverse = await pairWith('HG00438#1')
516
- const forward = await pairWith('HG01928#2')
517
- expect(reverse.get('strand')).toBe(-1)
518
- expect(forward.get('strand')).toBe(1)
519
- expect(largestGap(forward.get('CIGAR'))).toBeGreaterThan(0)
520
- expect(reverse.get('CIGAR')).toBe(forward.get('CIGAR'))
521
- })
522
-
523
- test('the pair with an indel between its lanes writes it', async () => {
524
- const records = await feats(anchoredAdapter(), insertionWindow, {
525
- queryAssemblyName: 'HG01361#2',
526
- targetAssemblyName: 'HG00673#1',
527
- })
528
- expect(records.some(f => largestGap(f.get('CIGAR')) > 0)).toBe(true)
529
- })
530
-
531
- test('sequence both lanes carry and GRCh38 lacks aligns inside the pair record', async () => {
532
- const adapter = anchoredAdapter()
533
- const againstReference = await feats(adapter, insertionWindow, {
534
- targetAssemblyName: 'HG01361#2',
535
- })
536
- expect(
537
- Math.max(...againstReference.map(f => largestGap(f.get('CIGAR')))),
538
- ).toBeGreaterThan(150)
539
- const records = await feats(adapter, insertionWindow, pair)
540
- for (const f of records) {
541
- expect(largestGap(f.get('CIGAR'))).toBeLessThan(50)
542
- }
543
- const aligned = records.reduce(
544
- (sum, f) => sum + f.get('end') - f.get('start'),
545
- 0,
546
- )
547
- expect(aligned).toBeGreaterThan(
548
- insertionWindow.end - insertionWindow.start + 150,
549
- )
550
- })
551
-
552
- test('a lane pair walks only its two lanes', async () => {
553
- const adapter = anchoredAdapter()
554
- const { db } = await (
555
- adapter as unknown as {
556
- graph: () => Promise<{ db: { getSubgraphForRange: unknown } }>
557
- }
558
- ).graph()
559
- const cut = vi.spyOn(db, 'getSubgraphForRange')
560
- await feats(adapter, insertionWindow, pair)
561
- const { keep } = cut.mock.calls.at(-1)![3] as {
562
- keep: (name: { sample: string; haplotype: number }) => boolean
563
- }
564
- const named = (sample: string, haplotype: number) => ({
565
- sample,
566
- haplotype,
567
- contig: '',
568
- fragment: 0,
569
- })
570
- expect(keep(named('HG01361', 2))).toBe(true)
571
- expect(keep(named('HG02145', 2))).toBe(true)
572
- expect(keep(named('HG01361', 1))).toBe(false)
573
- expect(keep(named('HG00438', 1))).toBe(false)
574
- cut.mockRestore()
575
- })
576
-
577
- test('lane pair ids are the same across two fetches of one window', async () => {
578
- const adapter = anchoredAdapter()
579
- const ids = async () =>
580
- (await feats(adapter, insertionWindow, pair)).map(f => f.id()).sort()
581
- const a = await ids()
582
- expect(new Set(a).size).toBe(a.length)
583
- expect(await ids()).toEqual(a)
584
- })
585
-
586
- test('a lane pair names its lanes the way the header does, through assemblyNameToPanSN', async () => {
587
- const adapter = anchoredAdapter({
588
- assemblyNameToPanSN: {
589
- hg38: 'GRCh38#0',
590
- 'HG01361.2': 'HG01361#2',
591
- 'HG02145.2': 'HG02145#2',
592
- },
593
- })
594
- const records = await feats(adapter, insertionWindow, {
595
- queryAssemblyName: 'HG01361.2',
596
- targetAssemblyName: 'HG02145.2',
597
- })
598
- expect(records.length).toBeGreaterThan(0)
599
- for (const f of records) {
600
- expect(f.get('assemblyName')).toBe('HG01361.2')
601
- expect(mateOf(f).assemblyName).toBe('HG02145.2')
602
- }
603
- })
604
-
605
- // the anchor window's numbers are no coordinates on a lane's contig, so a
606
- // clip against them would cut the records to nothing
607
- test('clipToRegion keeps a lane pair whole and drops its CIGAR', async () => {
608
- const adapter = anchoredAdapter()
609
- const whole = await feats(adapter, insertionWindow, pair)
610
- const clippedPairs = await firstValueFrom(
611
- adapter
612
- .getFeaturesInMultipleRegions([insertionWindow] as never, {
613
- ...pair,
614
- clipToRegion: true,
615
- splitAtGapBp: 10_000,
616
- })
617
- .pipe(toArray()),
618
- )
619
- expect(clippedPairs.map(f => [f.get('start'), f.get('end')])).toEqual(
620
- whole.map(f => [f.get('start'), f.get('end')]),
621
- )
622
- for (const f of clippedPairs) {
623
- expect(f.get('CIGAR')).toBeUndefined()
624
- }
625
- })
626
-
627
- // CHM13's contigs are several fragments each in HPRC v2.1, and a cut takes the
628
- // fragment its window starts in alone; this fixture's are whole, so a second
629
- // GRCh38 chr6 fragment is reported starting mid-window
630
- test('a lane pair across a reference fragment boundary is cut once per fragment', async () => {
631
- const adapter = anchoredAdapter()
632
- const { db } = await (
633
- adapter as unknown as {
634
- graph: () => Promise<{
635
- db: {
636
- paths: () => Promise<
637
- {
638
- isIndexed: boolean
639
- name: { sample: string; contig: string; fragment: number }
640
- }[]
641
- >
642
- getSubgraphForRange: unknown
643
- }
644
- }>
645
- }
646
- ).graph()
647
- const paths = await db.paths()
648
- const grch38 = paths.find(
649
- p => p.isIndexed && p.name.sample === 'GRCh38' && p.name.contig === 'chr6',
650
- )!
651
- const boundary = 31498700
652
- const listed = vi
653
- .spyOn(db, 'paths')
654
- .mockResolvedValue([
655
- ...paths,
656
- { ...grch38, name: { ...grch38.name, fragment: boundary } },
657
- ])
658
- const cut = vi.spyOn(db, 'getSubgraphForRange')
659
- const records = await feats(adapter, insertionWindow, pair)
660
- expect(cut.mock.calls.map(([, start, end]) => [start, end]).sort()).toEqual([
661
- [insertionWindow.start, boundary],
662
- [boundary, insertionWindow.end],
663
- ])
664
- expect(records.length).toBeGreaterThan(1)
665
- cut.mockRestore()
666
- listed.mockRestore()
667
- })
668
-
669
- test('a lane pair without its target lane is refused', async () => {
670
- await expect(
671
- feats(anchoredAdapter(), insertionWindow, {
672
- queryAssemblyName: 'HG01361#2',
673
- }),
674
- ).rejects.toThrow(PairTargetError)
675
- })
676
-
677
- // the micb database's own side tables hold no anchor rows, and at context 0
678
- // its sampled cut leaves both walks in pieces around the insertion
679
- test('without anchor rows a lane pair answers nothing, which the display composes through the reference', async () => {
680
- expect(await feats(makeAdapter(), insertionWindow, pair)).toEqual([])
681
- })
682
-
683
423
  test('getSubgraph outside every reference fragment is empty', async () => {
684
424
  expect(
685
425
  await makeAdapter().getSubgraph({ ...window, start: 100, end: 200 }),
@@ -15,13 +15,7 @@ import SyntenyFeature from '../synteny/SyntenyFeature.ts'
15
15
 
16
16
  import type { GbzBaseSyntenyAdapterConfig } from './configSchema.ts'
17
17
  import type { SubgraphAdapterOptions } from '../GetSubgraph.ts'
18
- import type {
19
- HaplotypeAlignment,
20
- HaplotypeRef,
21
- PairAlignment,
22
- PathName,
23
- PathQuery,
24
- } from '@gmod/gbz-base'
18
+ import type { HaplotypeAlignment, PathName, PathQuery } from '@gmod/gbz-base'
25
19
  import type { BaseOptions } from '@jbrowse/core/data_adapters/BaseAdapter'
26
20
  import type { Feature, SimpleFeatureSerialized } from '@jbrowse/core/util'
27
21
  import type { FileLocation, Region } from '@jbrowse/core/util/types'
@@ -77,23 +71,9 @@ export interface GbzHeaderLane {
77
71
  * `haplotypes` narrows a fetch to the lanes listed: PanSN prefixes at sample
78
72
  * (`HG002`) or haplotype (`HG002#1`) depth, or assembly names the config maps
79
73
  * to one; undefined is every haplotype.
80
- *
81
- * `queryAssemblyName` with `targetAssemblyName`, on a window of the anchor,
82
- * asks for that pair of lanes aligned to each other inside the window.
83
74
  */
84
75
  export interface GbzFeatureOptions extends ComparativeOptions {
85
76
  haplotypes?: string[]
86
- queryAssemblyName?: string
87
- }
88
-
89
- export class PairTargetError extends Error {
90
- override name = 'PairTargetError'
91
-
92
- constructor(queryAssemblyName: string) {
93
- super(
94
- `a pair query names both lanes: queryAssemblyName ${queryAssemblyName} came without a targetAssemblyName`,
95
- )
96
- }
97
77
  }
98
78
 
99
79
  function haplotypeWanted(name: PathName, wanted: string[] | undefined) {
@@ -233,44 +213,6 @@ export function fragmentFeature({
233
213
  }
234
214
  }
235
215
 
236
- /**
237
- * One record of a lane pair, on the target walk's own contig with the query
238
- * walk as its mate. gbz-base writes the CIGAR along the target, a `D` being
239
- * target bases the query lacks, which is the side a JBrowse feature is, so it
240
- * passes through unchanged.
241
- */
242
- export function pairFeature({
243
- pair,
244
- lane,
245
- mateLane,
246
- }: {
247
- pair: PairAlignment
248
- lane: string
249
- mateLane: string
250
- }) {
251
- const id = `${haplotypePrefix(pair.target)}#${pair.target.contig}:${pair.targetStart}-${pair.targetEnd}|${haplotypePrefix(pair.query)}#${pair.query.contig}:${pair.queryStart}-${pair.queryEnd}`
252
- return new SyntenyFeature({
253
- uniqueId: id,
254
- assemblyName: lane,
255
- refName: pair.target.contig,
256
- start: pair.targetStart,
257
- end: pair.targetEnd,
258
- type: 'match',
259
- strand: pair.strand === '-' ? -1 : 1,
260
- CIGAR: pair.cigar,
261
- syntenyId: id,
262
- identity: pair.matches / Math.max(pair.columns, 1),
263
- numMatches: pair.matches,
264
- blockLen: pair.columns,
265
- mate: {
266
- refName: pair.query.contig,
267
- start: pair.queryStart,
268
- end: pair.queryEnd,
269
- assemblyName: mateLane,
270
- },
271
- })
272
- }
273
-
274
216
  export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBaseSyntenyAdapterConfig> {
275
217
  private graph = cachedSetup({
276
218
  label: 'Opening pangenome database',
@@ -466,45 +408,6 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
466
408
  return subgraph ? subgraph.toGFA({ names: 'resolved' }) : ''
467
409
  }
468
410
 
469
- private async laneHaplotypes(
470
- assemblyName: string,
471
- opts: BaseOptions,
472
- ): Promise<HaplotypeRef[]> {
473
- const prefix = resolvePanSNPrefix(this, assemblyName)
474
- return (await this.getHaplotypes(opts)).filter(haplotype =>
475
- panSNMatchesPrefix(haplotype.prefix, prefix),
476
- )
477
- }
478
-
479
- /**
480
- * The window split wherever the reference sample's contig starts another
481
- * fragment, so each piece lies inside one: getSubgraphForRange cuts the
482
- * first fragment alone, where getAlignmentsForRange walks them all
483
- */
484
- private async referencePieces(
485
- refName: string,
486
- start: number,
487
- end: number,
488
- opts: BaseOptions,
489
- ) {
490
- const { db, referenceSample } = await this.graph(opts)
491
- const inside = (await db.paths())
492
- .filter(
493
- path =>
494
- path.isIndexed &&
495
- path.name.sample === referenceSample &&
496
- path.name.contig === refName &&
497
- path.name.fragment > start &&
498
- path.name.fragment < end,
499
- )
500
- .map(path => path.name.fragment)
501
- const bounds = [start, ...[...new Set(inside)].sort((a, b) => a - b), end]
502
- return bounds.slice(1).map((pieceEnd, i) => ({
503
- start: bounds[i]!,
504
- end: pieceEnd,
505
- }))
506
- }
507
-
508
411
  private async anchorFeatures(region: Region, opts: GbzFeatureOptions) {
509
412
  const { db } = await this.graph(opts)
510
413
  const { assemblyName, refName, start, end } = region
@@ -544,87 +447,6 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
544
447
  })
545
448
  }
546
449
 
547
- /**
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.
551
- *
552
- * Only a cut walked from the haplotype index's anchor rows holds each walk
553
- * whole. The sampled cut leaves a walk in pieces wherever it strays past the
554
- * context, and pieces align only in part, so without anchor rows, or when
555
- * the anchor walk of any reference fragment in the window fell back, the
556
- * pair answers nothing and the display composes it through the reference.
557
- */
558
- private async pairFeatures(
559
- region: Region,
560
- queryAssemblyName: string,
561
- opts: GbzFeatureOptions,
562
- ) {
563
- const { targetAssemblyName } = opts
564
- if (targetAssemblyName === undefined) {
565
- throw new PairTargetError(queryAssemblyName)
566
- }
567
- const { db } = await this.graph(opts)
568
- const { refName, start, end } = region
569
- const featureSide = await this.laneHaplotypes(queryAssemblyName, opts)
570
- const mateSide = await this.laneHaplotypes(targetAssemblyName, opts)
571
- const kept = [...featureSide, ...mateSide]
572
- const query = await this.referenceQuery(refName, opts)
573
- const nodeLimit: number = this.getConf('nodeLimit')
574
- const anchored = (await db.haplotypeAnchorSpacing()) !== undefined
575
- const cut = (reference: PathQuery, piece: { start: number; end: number }) =>
576
- db
577
- .getSubgraphForRange(reference, piece.start, piece.end, {
578
- context: this.getConf('context'),
579
- haplotypes: 'all',
580
- limit: nodeLimit,
581
- signal: opts.signal,
582
- keep: name =>
583
- kept.some(
584
- haplotype =>
585
- haplotype.sample === name.sample &&
586
- haplotype.haplotype === name.haplotype,
587
- ),
588
- })
589
- .catch((error: unknown) => {
590
- throw nodeLimitError(error, nodeLimit, end - start) ?? error
591
- })
592
- const subgraphs =
593
- query && anchored && featureSide.length > 0 && mateSide.length > 0
594
- ? (
595
- await updateStatus(
596
- `Aligning ${queryAssemblyName} to ${targetAssemblyName}`,
597
- opts.statusCallback,
598
- async () =>
599
- Promise.all(
600
- (await this.referencePieces(refName, start, end, opts)).map(
601
- piece => cut(query, piece),
602
- ),
603
- ),
604
- )
605
- ).filter(subgraph => subgraph !== undefined)
606
- : []
607
- const whole = subgraphs.every(subgraph => {
608
- const walk = subgraph.stats.anchorWalk
609
- return walk !== undefined && walk.fallback === undefined
610
- })
611
- return whole
612
- ? subgraphs.flatMap(subgraph =>
613
- featureSide.flatMap(target =>
614
- mateSide.flatMap(mate =>
615
- subgraph.pairAlignments({ target, query: mate }).map(pair =>
616
- pairFeature({
617
- pair,
618
- lane: queryAssemblyName,
619
- mateLane: targetAssemblyName,
620
- }),
621
- ),
622
- ),
623
- ),
624
- )
625
- : []
626
- }
627
-
628
450
  getFeatures(region: Region, opts: GbzFeatureOptions = {}) {
629
451
  return ObservableCreate<Feature>(async observer => {
630
452
  const { db, anchor } = await this.graph(opts)
@@ -632,12 +454,9 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
632
454
  throw new NoHaplotypeIndexError()
633
455
  }
634
456
  // the graph is indexed on its reference alone, so a window on a
635
- // haplotype lane has no answer: a lane pair is read inside the anchor's
457
+ // haplotype lane has no answer
636
458
  if (region.assemblyName === anchor) {
637
- const features =
638
- opts.queryAssemblyName === undefined
639
- ? await this.anchorFeatures(region, opts)
640
- : await this.pairFeatures(region, opts.queryAssemblyName, opts)
459
+ const features = await this.anchorFeatures(region, opts)
641
460
  for (const feature of features) {
642
461
  observer.next(feature)
643
462
  }
@@ -16,14 +16,7 @@ export default function GbzBaseSyntenyAdapterF(pluginManager: PluginManager) {
16
16
  // headerLanes: getHeader declares every haplotype as a lane, which is
17
17
  // what makes MultiWaySyntenyDisplay read the header of an untiered
18
18
  // adapter and offer its lane picker over the whole graph.
19
- // lanePairsOnAnchor: a window of the anchor answers any two lanes'
20
- // alignment to each other, so the display fetches each adjacent pair
21
- // rather than composing it through the reference
22
- adapterCapabilities: [
23
- 'getSubgraph',
24
- 'headerLanes',
25
- 'lanePairsOnAnchor',
26
- ],
19
+ adapterCapabilities: ['getSubgraph', 'headerLanes'],
27
20
  adapterMetadata: {
28
21
  category: 'Synteny adapters',
29
22
  },
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '3.0.4'
1
+ export const version = '3.0.5'