jbrowse-plugin-graphgenomeviewer 3.0.3 → 3.0.4

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.3",
3
+ "version": "3.0.4",
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,7 +25,7 @@
25
25
  ],
26
26
  "private": false,
27
27
  "dependencies": {
28
- "@gmod/gbz-base": "^2.6.5",
28
+ "@gmod/gbz-base": "^2.7.0",
29
29
  "@gmod/tabix": "^3.8.2",
30
30
  "@jbrowse/cigar-utils": "^5.0.0-beta.9",
31
31
  "@jbrowse/core": "^5.0.0-beta.9",
@@ -3,6 +3,7 @@ import { toArray } from 'rxjs/operators'
3
3
 
4
4
  import Adapter, {
5
5
  NoReferenceSampleError,
6
+ PairTargetError,
6
7
  laneAssemblyName,
7
8
  } from './GbzBaseSyntenyAdapter.ts'
8
9
  import configSchema from './configSchema.ts'
@@ -420,6 +421,265 @@ test('getSubgraph refuses a window on a haplotype lane with a message naming the
420
421
  ).rejects.toThrow(/cut on its reference, hg38; a window on HG01106#1/)
421
422
  })
422
423
 
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
+
423
683
  test('getSubgraph outside every reference fragment is empty', async () => {
424
684
  expect(
425
685
  await makeAdapter().getSubgraph({ ...window, start: 100, end: 200 }),
@@ -15,7 +15,13 @@ 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 { HaplotypeAlignment, PathName, PathQuery } from '@gmod/gbz-base'
18
+ import type {
19
+ HaplotypeAlignment,
20
+ HaplotypeRef,
21
+ PairAlignment,
22
+ PathName,
23
+ PathQuery,
24
+ } from '@gmod/gbz-base'
19
25
  import type { BaseOptions } from '@jbrowse/core/data_adapters/BaseAdapter'
20
26
  import type { Feature, SimpleFeatureSerialized } from '@jbrowse/core/util'
21
27
  import type { FileLocation, Region } from '@jbrowse/core/util/types'
@@ -71,9 +77,23 @@ export interface GbzHeaderLane {
71
77
  * `haplotypes` narrows a fetch to the lanes listed: PanSN prefixes at sample
72
78
  * (`HG002`) or haplotype (`HG002#1`) depth, or assembly names the config maps
73
79
  * 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.
74
83
  */
75
84
  export interface GbzFeatureOptions extends ComparativeOptions {
76
85
  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
+ }
77
97
  }
78
98
 
79
99
  function haplotypeWanted(name: PathName, wanted: string[] | undefined) {
@@ -213,6 +233,44 @@ export function fragmentFeature({
213
233
  }
214
234
  }
215
235
 
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
+
216
274
  export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBaseSyntenyAdapterConfig> {
217
275
  private graph = cachedSetup({
218
276
  label: 'Opening pangenome database',
@@ -408,51 +466,180 @@ export default class GbzBaseSyntenyAdapter extends ComparativeAdapterBase<GbzBas
408
466
  return subgraph ? subgraph.toGFA({ names: 'resolved' }) : ''
409
467
  }
410
468
 
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
+ private async anchorFeatures(region: Region, opts: GbzFeatureOptions) {
509
+ const { db } = await this.graph(opts)
510
+ const { assemblyName, refName, start, end } = region
511
+ const targetPrefix = resolvePanSNPrefix(this, opts.targetAssemblyName)
512
+ const asmByPrefix = assemblyByPanSNPrefix(this)
513
+ const keep = this.keepPredicate(opts.haplotypes, targetPrefix)
514
+ const query = await this.referenceQuery(refName, opts)
515
+ const nodeLimit: number = this.getConf('nodeLimit')
516
+ const alignments = query
517
+ ? await updateStatus(
518
+ `Reading graph ${refName}:${start.toLocaleString()}-${end.toLocaleString()}`,
519
+ opts.statusCallback,
520
+ () =>
521
+ db
522
+ .getAlignmentsForRange(query, start, end, {
523
+ context: this.getConf('context'),
524
+ haplotypes: 'all',
525
+ limit: nodeLimit,
526
+ signal: opts.signal,
527
+ ...(keep === undefined ? {} : { keep }),
528
+ })
529
+ .catch((error: unknown) => {
530
+ throw nodeLimitError(error, nodeLimit, end - start) ?? error
531
+ }),
532
+ )
533
+ : []
534
+ return alignments.flatMap(alignment => {
535
+ const feature = alignment.resolved
536
+ ? fragmentFeature({
537
+ alignment,
538
+ assemblyName,
539
+ refName,
540
+ lane: laneAssemblyName(asmByPrefix, alignment.name),
541
+ })
542
+ : undefined
543
+ return feature === undefined ? [] : [feature]
544
+ })
545
+ }
546
+
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
+
411
628
  getFeatures(region: Region, opts: GbzFeatureOptions = {}) {
412
629
  return ObservableCreate<Feature>(async observer => {
413
630
  const { db, anchor } = await this.graph(opts)
414
631
  if (!db.hasHaplotypeIndex) {
415
632
  throw new NoHaplotypeIndexError()
416
633
  }
417
- const { assemblyName, refName, start, end } = region
418
- // a window on a haplotype lane has no direct answer: the display
419
- // composes lane-to-lane links through the anchor
420
- if (assemblyName === anchor) {
421
- const targetPrefix = resolvePanSNPrefix(this, opts.targetAssemblyName)
422
- const asmByPrefix = assemblyByPanSNPrefix(this)
423
- const keep = this.keepPredicate(opts.haplotypes, targetPrefix)
424
- const query = await this.referenceQuery(refName, opts)
425
- const nodeLimit: number = this.getConf('nodeLimit')
426
- const alignments = query
427
- ? await updateStatus(
428
- `Reading graph ${refName}:${start.toLocaleString()}-${end.toLocaleString()}`,
429
- opts.statusCallback,
430
- () =>
431
- db
432
- .getAlignmentsForRange(query, start, end, {
433
- context: this.getConf('context'),
434
- haplotypes: 'all',
435
- limit: nodeLimit,
436
- signal: opts.signal,
437
- ...(keep === undefined ? {} : { keep }),
438
- })
439
- .catch((error: unknown) => {
440
- throw nodeLimitError(error, nodeLimit, end - start) ?? error
441
- }),
442
- )
443
- : []
444
- for (const alignment of alignments) {
445
- if (alignment.resolved) {
446
- const feature = fragmentFeature({
447
- alignment,
448
- assemblyName,
449
- refName,
450
- lane: laneAssemblyName(asmByPrefix, alignment.name),
451
- })
452
- if (feature !== undefined) {
453
- observer.next(feature)
454
- }
455
- }
634
+ // 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
636
+ 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)
641
+ for (const feature of features) {
642
+ observer.next(feature)
456
643
  }
457
644
  }
458
645
  observer.complete()
@@ -15,8 +15,15 @@ export default function GbzBaseSyntenyAdapterF(pluginManager: PluginManager) {
15
15
  // launch menu offers this track too (launchSubgraph/subgraphTracks).
16
16
  // headerLanes: getHeader declares every haplotype as a lane, which is
17
17
  // what makes MultiWaySyntenyDisplay read the header of an untiered
18
- // adapter and offer its lane picker over the whole graph
19
- adapterCapabilities: ['getSubgraph', 'headerLanes'],
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
+ ],
20
27
  adapterMetadata: {
21
28
  category: 'Synteny adapters',
22
29
  },
@@ -41,7 +41,10 @@ export abstract class ComparativeAdapterBase<
41
41
 
42
42
  /**
43
43
  * `clipToRegion` and `splitAtGapBp` are honoured here and nowhere below:
44
- * `getFeatures` never sees them.
44
+ * `getFeatures` never sees them. A record on another assembly than the
45
+ * region's, such as a lane pair read inside an anchor window, is in no
46
+ * coordinates the region states, so it is split at its gaps and clipped to
47
+ * its own extent.
45
48
  *
46
49
  * Emission is in region order, not arrival order, as core's base does: the
47
50
  * multi-way display's lane sort tie-breaks on first appearance in this list,
@@ -59,7 +62,13 @@ export abstract class ComparativeAdapterBase<
59
62
  this.getFeatures(region, { ...rest, statusCallback: slot() }).pipe(
60
63
  mergeMap((feature): Feature[] =>
61
64
  clip
62
- ? clipFeatureToRegion(feature, region, splitAtGapBp)
65
+ ? clipFeatureToRegion(
66
+ feature,
67
+ feature.get('assemblyName') === region.assemblyName
68
+ ? region
69
+ : { start: feature.get('start'), end: feature.get('end') },
70
+ splitAtGapBp,
71
+ )
63
72
  : [feature],
64
73
  ),
65
74
  toArray(),
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '3.0.3'
1
+ export const version = '3.0.4'