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/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js +8 -0
- package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js.map +7 -0
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +2 -2
- package/package.json +2 -2
- package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.test.ts +260 -0
- package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.ts +227 -40
- package/src/GbzBaseSyntenyAdapter/index.ts +9 -2
- package/src/synteny/ComparativeAdapterBase.ts +11 -2
- package/src/version.ts +1 -1
- package/dist/chunks/GbzBaseSyntenyAdapter-EFIOI2SN.js +0 -8
- package/dist/chunks/GbzBaseSyntenyAdapter-EFIOI2SN.js.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jbrowse-plugin-graphgenomeviewer",
|
|
3
|
-
"version": "3.0.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1
|
+
export const version = '3.0.4'
|