jbrowse-plugin-graphgenomeviewer 4.0.11 → 4.0.13
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/README.md +22 -4
- package/dist/chunks/GbzBaseSyntenyAdapter-Y5NBXK7P.js +8 -0
- package/dist/chunks/{GbzBaseSyntenyAdapter-URQ4Z2JB.js.map → GbzBaseSyntenyAdapter-Y5NBXK7P.js.map} +4 -4
- package/dist/chunks/{GraphGenomeView-44GJL4RA.js → GraphGenomeView-TTQEJJQD.js} +2 -2
- package/dist/chunks/{GraphGenomeView-44GJL4RA.js.map → GraphGenomeView-TTQEJJQD.js.map} +1 -1
- package/dist/chunks/GraphTrackSettingsDialog-3A2PKZRI.js +4 -0
- package/dist/chunks/{GraphTrackSettingsDialog-LZITZ3VD.js.map → GraphTrackSettingsDialog-3A2PKZRI.js.map} +1 -1
- package/dist/chunks/LinearGraphDisplay-MJ5XZFE5.js +2 -0
- package/dist/chunks/{LinearGraphDisplay-BWBXVSEQ.js.map → LinearGraphDisplay-MJ5XZFE5.js.map} +1 -1
- package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js → MinigraphBubbleAdapter-APC7JYOM.js} +2 -2
- package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js → RgfaTabixAdapter-4E365PXO.js} +2 -2
- package/dist/chunks/{chunk-LGGAXAS6.js → chunk-5DP7IXD3.js} +2 -2
- package/dist/chunks/chunk-CJ56O2VG.js +2 -0
- package/dist/chunks/chunk-CJ56O2VG.js.map +7 -0
- package/dist/chunks/chunk-EFVAQ2KM.js +2 -0
- package/dist/chunks/chunk-EFVAQ2KM.js.map +7 -0
- package/dist/chunks/chunk-IFRDWMQ2.js +2 -0
- package/dist/chunks/chunk-IFRDWMQ2.js.map +7 -0
- package/dist/chunks/chunk-JAQ2RQAE.js +2 -0
- package/dist/chunks/{chunk-QRVREF7H.js.map → chunk-JAQ2RQAE.js.map} +4 -4
- package/dist/chunks/{chunk-WETM2CD7.js → chunk-LA7MQS33.js} +2 -2
- package/dist/chunks/chunk-M4YEAXSR.js +2 -0
- package/dist/chunks/chunk-M4YEAXSR.js.map +7 -0
- package/dist/chunks/chunk-XDOIZTY6.js +4 -0
- package/dist/chunks/chunk-XDOIZTY6.js.map +7 -0
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
- package/package.json +4 -4
- package/src/GbzBaseSyntenyAdapter/configSchema.ts +3 -4
- package/src/GraphGenomeView/components/GraphCanvas.tsx +4 -11
- package/src/GraphGenomeView/components/TubeMapOverlay.tsx +117 -4
- package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +261 -44
- package/src/GraphGenomeView/components/referenceRampCss.test.ts +27 -0
- package/src/GraphGenomeView/components/referenceRampCss.ts +39 -0
- package/src/GraphGenomeView/genes/genes.test.ts +9 -2
- package/src/GraphGenomeView/host.test.ts +25 -2
- package/src/GraphGenomeView/host.ts +9 -7
- package/src/GraphGenomeView/model.test.ts +383 -4
- package/src/GraphGenomeView/model.ts +413 -88
- package/src/GraphGenomeView/repeats/repeatFeatures.test.ts +75 -0
- package/src/GraphGenomeView/repeats/repeatFeatures.ts +156 -8
- package/src/GraphGenomeView/repeats/walkCalls.test.ts +18 -0
- package/src/GraphGenomeView/repeats/walkCalls.ts +35 -18
- package/src/GraphGenomeView/subgraphLoad.test.ts +1 -0
- package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +165 -6
- package/src/LinearGraphDisplay/model.ts +61 -1
- package/src/gaf/gafFile.test.ts +18 -0
- package/src/version.ts +1 -1
- package/dist/chunks/GbzBaseSyntenyAdapter-URQ4Z2JB.js +0 -8
- package/dist/chunks/GraphTrackSettingsDialog-LZITZ3VD.js +0 -4
- package/dist/chunks/LinearGraphDisplay-BWBXVSEQ.js +0 -2
- package/dist/chunks/chunk-ADEBM7AA.js +0 -2
- package/dist/chunks/chunk-ADEBM7AA.js.map +0 -7
- package/dist/chunks/chunk-MWSRAF5B.js +0 -4
- package/dist/chunks/chunk-MWSRAF5B.js.map +0 -7
- package/dist/chunks/chunk-QRVREF7H.js +0 -2
- package/dist/chunks/chunk-ZYQJBNS5.js +0 -2
- package/dist/chunks/chunk-ZYQJBNS5.js.map +0 -7
- /package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js.map → MinigraphBubbleAdapter-APC7JYOM.js.map} +0 -0
- /package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js.map → RgfaTabixAdapter-4E365PXO.js.map} +0 -0
- /package/dist/chunks/{chunk-LGGAXAS6.js.map → chunk-5DP7IXD3.js.map} +0 -0
- /package/dist/chunks/{chunk-WETM2CD7.js.map → chunk-LA7MQS33.js.map} +0 -0
|
@@ -112,3 +112,78 @@ test("a TRGT VCF's per-sample AL and SD are each sample's called alleles", () =>
|
|
|
112
112
|
})
|
|
113
113
|
expect(repeatArraysFrom([{ ...base, period: 2 }])[0]!.calls).toBe(undefined)
|
|
114
114
|
})
|
|
115
|
+
|
|
116
|
+
// Two copies of unit B then three of A, and a second allele of four A: RN
|
|
117
|
+
// splits the flattened lists 2 + 1, and RUB lists every copy of every run.
|
|
118
|
+
const A = 'ACGTACGTAC'
|
|
119
|
+
const B = 'ACGTTCGTAC'
|
|
120
|
+
const tandem = {
|
|
121
|
+
...base,
|
|
122
|
+
ALT: ['<CNV:TR>', '<CNV:TR>'],
|
|
123
|
+
INFO: {
|
|
124
|
+
SVLEN: [30751, 30751],
|
|
125
|
+
RN: [2, 1],
|
|
126
|
+
RUS: [B, A, A],
|
|
127
|
+
RUC: [2, 3, 4],
|
|
128
|
+
RB: [20, 29, 40],
|
|
129
|
+
RUB: [10, 10, 10, 10, 9, 10, 10, 10, 10],
|
|
130
|
+
},
|
|
131
|
+
samples: {
|
|
132
|
+
HG00128: { GT: ['1|2'] },
|
|
133
|
+
HG00133: { GT: ['.|1'] },
|
|
134
|
+
GRCh38: { GT: ['0'] },
|
|
135
|
+
HG00099: { GT: ['2/1'] },
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test("VCF 4.5 <CNV:TR>: each allele's runs off RN, RUS, RUC, RB and RUB", () => {
|
|
140
|
+
const [array] = repeatArraysFrom([tandem])
|
|
141
|
+
expect(array).toMatchObject({ start: 160616003, end: 160646754 })
|
|
142
|
+
const bThenA = [
|
|
143
|
+
{ unit: B, unitLength: 10, count: 2, bp: 20, copyBp: [10, 10] },
|
|
144
|
+
{ unit: A, unitLength: 10, count: 3, bp: 29, copyBp: [10, 10, 9] },
|
|
145
|
+
]
|
|
146
|
+
const fourA = [
|
|
147
|
+
{ unit: A, unitLength: 10, count: 4, bp: 40, copyBp: [10, 10, 10, 10] },
|
|
148
|
+
]
|
|
149
|
+
expect(array!.calls).toEqual({
|
|
150
|
+
HG00128: [
|
|
151
|
+
{ bp: 49, sequences: bThenA, haplotype: 1 },
|
|
152
|
+
{ bp: 40, sequences: fourA, haplotype: 2 },
|
|
153
|
+
],
|
|
154
|
+
HG00133: [{ bp: 49, sequences: bThenA, haplotype: 2 }],
|
|
155
|
+
GRCh38: [{ bp: 30751 }],
|
|
156
|
+
HG00099: [
|
|
157
|
+
{ bp: 40, sequences: fourA },
|
|
158
|
+
{ bp: 49, sequences: bThenA },
|
|
159
|
+
],
|
|
160
|
+
})
|
|
161
|
+
expect(array!.units).toEqual([
|
|
162
|
+
{ unit: A, unitLength: 10, copies: 7 },
|
|
163
|
+
{ unit: B, unitLength: 10, copies: 2 },
|
|
164
|
+
])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('a run stating only RUL is keyed by its length, and a summary record states no calls', () => {
|
|
168
|
+
const [array] = repeatArraysFrom([
|
|
169
|
+
{
|
|
170
|
+
...base,
|
|
171
|
+
ALT: ['<CNV:TR>'],
|
|
172
|
+
INFO: { RN: [1], RUL: [5548], RUC: [5.5] },
|
|
173
|
+
samples: { HG00097: { GT: ['1'] } },
|
|
174
|
+
},
|
|
175
|
+
])
|
|
176
|
+
expect(array!.calls!.HG00097).toEqual([
|
|
177
|
+
{
|
|
178
|
+
bp: 30514,
|
|
179
|
+
sequences: [{ unit: '5548', unitLength: 5548, count: 5.5, bp: 30514 }],
|
|
180
|
+
},
|
|
181
|
+
])
|
|
182
|
+
const [summary] = repeatArraysFrom([
|
|
183
|
+
{ ...base, ALT: ['<CNV:TR>'], INFO: { RUL: '5548', RUC: '5.5' } },
|
|
184
|
+
])
|
|
185
|
+
expect(summary!.calls).toBeUndefined()
|
|
186
|
+
expect(summary!.units).toEqual([
|
|
187
|
+
{ unit: '5548', unitLength: 5548, copies: 5.5 },
|
|
188
|
+
])
|
|
189
|
+
})
|
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import type { Feature } from '@jbrowse/core/util'
|
|
2
2
|
|
|
3
|
+
// One run of an allele as VCF 4.5 states a repeat sequence: `count` copies of
|
|
4
|
+
// one unit. `unit` is the unit's sequence where the record gives one (RUS),
|
|
5
|
+
// else its length (RUL), and is what walk rows colour a run by.
|
|
6
|
+
export interface RepeatSequence {
|
|
7
|
+
unit: string
|
|
8
|
+
unitLength: number
|
|
9
|
+
count: number
|
|
10
|
+
bp: number
|
|
11
|
+
// each copy's bases (RUB), where copies differ in length
|
|
12
|
+
copyBp?: number[]
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export interface RepeatCall {
|
|
4
16
|
bp: number
|
|
5
17
|
// TRGT's `SD`. Zero means the allele is a copy of the one the reads did
|
|
6
18
|
// support, so its length states nothing about a second haplotype; absent
|
|
7
19
|
// where the genotyper writes no such field.
|
|
8
20
|
spanningReads?: number
|
|
21
|
+
// the allele's runs in order, where the record states them
|
|
22
|
+
sequences?: RepeatSequence[]
|
|
23
|
+
// the PanSN haplotype a phased genotype puts this allele on
|
|
24
|
+
haplotype?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A unit the record's alleles repeat, with the copies of it they carry
|
|
28
|
+
// between them.
|
|
29
|
+
export interface RepeatUnit {
|
|
30
|
+
unit: string
|
|
31
|
+
unitLength: number
|
|
32
|
+
copies: number
|
|
9
33
|
}
|
|
10
34
|
|
|
11
35
|
// A tandem repeat array as the walk rows need it: its span on the reference,
|
|
@@ -28,8 +52,11 @@ export interface RepeatArray {
|
|
|
28
52
|
unit: number
|
|
29
53
|
motif?: string
|
|
30
54
|
// Each sample's genotyped alleles, from a genotyper's per-sample `AL`
|
|
31
|
-
// (TRGT); absent from a
|
|
55
|
+
// (TRGT) or a genotype over VCF 4.5 <CNV:TR> alleles; absent from a
|
|
56
|
+
// catalogue.
|
|
32
57
|
calls?: Record<string, RepeatCall[]>
|
|
58
|
+
// the units the <CNV:TR> alleles repeat, most copies first
|
|
59
|
+
units?: RepeatUnit[]
|
|
33
60
|
}
|
|
34
61
|
|
|
35
62
|
export const REPEAT_ADAPTER_TYPES = new Set([
|
|
@@ -153,29 +180,148 @@ function numbers(value: unknown) {
|
|
|
153
180
|
})
|
|
154
181
|
}
|
|
155
182
|
|
|
156
|
-
function
|
|
183
|
+
function strings(value: unknown) {
|
|
184
|
+
const raw: unknown[] = Array.isArray(value)
|
|
185
|
+
? value
|
|
186
|
+
: String(value ?? '').split(',')
|
|
187
|
+
return raw.map(one => {
|
|
188
|
+
const text = String(one ?? '').trim()
|
|
189
|
+
return text === '' || text === '.' ? undefined : text
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const TANDEM_REPEAT = '<CNV:TR>'
|
|
194
|
+
|
|
195
|
+
// Each ALT allele's runs, off VCF 4.5's flattened list-of-lists: RN says how
|
|
196
|
+
// many RUS/RUL/RUC/RB entries each allele takes, and RUB holds one entry per
|
|
197
|
+
// copy of every run. An allele that is not <CNV:TR>, or has a run stating no
|
|
198
|
+
// unit, has none.
|
|
199
|
+
function tandemAlleles(f: FeatureLike) {
|
|
200
|
+
const alts = strings(own(f, 'ALT'))
|
|
201
|
+
if (!alts.includes(TANDEM_REPEAT)) {
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
const rn = numbers(field(f, 'RN'))
|
|
205
|
+
const rus = strings(field(f, 'RUS'))
|
|
206
|
+
const rul = numbers(field(f, 'RUL'))
|
|
207
|
+
const ruc = numbers(field(f, 'RUC'))
|
|
208
|
+
const rb = numbers(field(f, 'RB'))
|
|
209
|
+
const rub = numbers(field(f, 'RUB')).filter(bp => bp !== undefined)
|
|
210
|
+
const counted = ruc.every(Number.isInteger)
|
|
211
|
+
let k = 0
|
|
212
|
+
let copy = 0
|
|
213
|
+
return alts.map((alt, i) => {
|
|
214
|
+
const n = rn[i] ?? (alt === TANDEM_REPEAT ? 1 : 0)
|
|
215
|
+
const runs: RepeatSequence[] = []
|
|
216
|
+
for (let j = 0; j < n; j++, k++) {
|
|
217
|
+
const stated = rus[k]
|
|
218
|
+
const sequence = stated && IUPAC.test(stated) ? stated : undefined
|
|
219
|
+
const unitLength = rul[k] ?? sequence?.length
|
|
220
|
+
const bp = rb[k]
|
|
221
|
+
const count =
|
|
222
|
+
ruc[k] ?? (unitLength && bp !== undefined ? bp / unitLength : undefined)
|
|
223
|
+
if (unitLength && count !== undefined) {
|
|
224
|
+
const copyBp =
|
|
225
|
+
counted && rub.length > 0 ? rub.slice(copy, copy + count) : undefined
|
|
226
|
+
runs.push({
|
|
227
|
+
unit: sequence ?? String(unitLength),
|
|
228
|
+
unitLength,
|
|
229
|
+
count,
|
|
230
|
+
bp: bp ?? Math.round(unitLength * count),
|
|
231
|
+
...(copyBp?.length === count ? { copyBp } : {}),
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
copy += count ?? 0
|
|
235
|
+
}
|
|
236
|
+
return alt === TANDEM_REPEAT && runs.length === n ? runs : undefined
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function unitsOf(alleles: (RepeatSequence[] | undefined)[]) {
|
|
241
|
+
const units = new Map<string, RepeatUnit>()
|
|
242
|
+
for (const { unit, unitLength, count } of alleles.flatMap(r => r ?? [])) {
|
|
243
|
+
const copies = (units.get(unit)?.copies ?? 0) + count
|
|
244
|
+
units.set(unit, { unit, unitLength, copies })
|
|
245
|
+
}
|
|
246
|
+
return units.size > 0
|
|
247
|
+
? [...units.values()].sort(
|
|
248
|
+
(a, b) =>
|
|
249
|
+
b.copies - a.copies ||
|
|
250
|
+
a.unitLength - b.unitLength ||
|
|
251
|
+
a.unit.localeCompare(b.unit),
|
|
252
|
+
)
|
|
253
|
+
: undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A sample's alleles through its genotype: 0 is the reference's, an index
|
|
257
|
+
// past it the ALT allele's runs. A phased genotype's k-th allele is PanSN
|
|
258
|
+
// haplotype k, the order vg deconstruct writes an assembly's haplotypes in.
|
|
259
|
+
function genotyped(
|
|
260
|
+
gt: string | undefined,
|
|
261
|
+
alleles: (RepeatSequence[] | undefined)[],
|
|
262
|
+
referenceBp: number,
|
|
263
|
+
) {
|
|
264
|
+
const phased = gt?.includes('|')
|
|
265
|
+
return (gt ?? '').split(/[/|]/).flatMap((index, k): RepeatCall[] => {
|
|
266
|
+
const i = Number(index)
|
|
267
|
+
const sequences = i > 0 ? alleles[i - 1] : undefined
|
|
268
|
+
if (!Number.isInteger(i) || (i > 0 && !sequences)) {
|
|
269
|
+
return []
|
|
270
|
+
}
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
bp: sequences
|
|
274
|
+
? sequences.reduce((sum, run) => sum + run.bp, 0)
|
|
275
|
+
: referenceBp,
|
|
276
|
+
...(sequences ? { sequences } : {}),
|
|
277
|
+
...(phased ? { haplotype: k + 1 } : {}),
|
|
278
|
+
},
|
|
279
|
+
]
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function callsOf(
|
|
284
|
+
f: FeatureLike,
|
|
285
|
+
alleles: (RepeatSequence[] | undefined)[] | undefined,
|
|
286
|
+
referenceBp: number,
|
|
287
|
+
) {
|
|
157
288
|
const samples = own(f, 'samples') as
|
|
158
289
|
Record<string, Record<string, unknown>> | undefined
|
|
159
290
|
const called: Record<string, RepeatCall[]> = {}
|
|
160
291
|
for (const [sample, fields] of Object.entries(samples ?? {})) {
|
|
161
292
|
const reads = numbers(fields.SD)
|
|
162
|
-
const
|
|
293
|
+
const lengths = numbers(fields.AL).flatMap((bp, i) =>
|
|
163
294
|
bp !== undefined && bp > 0
|
|
164
295
|
? [reads[i] === undefined ? { bp } : { bp, spanningReads: reads[i] }]
|
|
165
296
|
: [],
|
|
166
297
|
)
|
|
167
|
-
|
|
168
|
-
|
|
298
|
+
const calls =
|
|
299
|
+
lengths.length > 0 || !alleles
|
|
300
|
+
? lengths
|
|
301
|
+
: genotyped(strings(fields.GT)[0], alleles, referenceBp)
|
|
302
|
+
if (calls.length > 0) {
|
|
303
|
+
called[sample] = calls
|
|
169
304
|
}
|
|
170
305
|
}
|
|
171
306
|
return Object.keys(called).length > 0 ? called : undefined
|
|
172
307
|
}
|
|
173
308
|
|
|
309
|
+
// A <CNV:TR> record's POS is the base before the array and SVLEN the
|
|
310
|
+
// reference allele's length, while a VCF feature starts at that padding base.
|
|
311
|
+
function spanOf(f: FeatureLike, alleles: unknown[] | undefined) {
|
|
312
|
+
const start = field(f, 'start') as number
|
|
313
|
+
const end = field(f, 'end') as number
|
|
314
|
+
const svlen = numbers(field(f, 'SVLEN'))[0]
|
|
315
|
+
return alleles && svlen !== undefined
|
|
316
|
+
? { start: start + 1, end: start + 1 + svlen }
|
|
317
|
+
: { start, end }
|
|
318
|
+
}
|
|
319
|
+
|
|
174
320
|
export function repeatArraysFrom(features: FeatureLike[]): RepeatArray[] {
|
|
175
321
|
const arrays: RepeatArray[] = []
|
|
176
322
|
for (const f of features) {
|
|
177
|
-
const
|
|
178
|
-
const end =
|
|
323
|
+
const alleles = tandemAlleles(f)
|
|
324
|
+
const { start, end } = spanOf(f, alleles)
|
|
179
325
|
const unit = repeatUnitOf(f)
|
|
180
326
|
if (!(end > start) || unit === undefined) {
|
|
181
327
|
continue
|
|
@@ -183,6 +329,7 @@ export function repeatArraysFrom(features: FeatureLike[]): RepeatArray[] {
|
|
|
183
329
|
const refName = field(f, 'refName') as string
|
|
184
330
|
const motif = first(f, MOTIF_FIELDS)
|
|
185
331
|
const stated = first(f, NAME_FIELDS)
|
|
332
|
+
const units = alleles && unitsOf(alleles)
|
|
186
333
|
arrays.push({
|
|
187
334
|
key: `${refName}:${start}-${end}`,
|
|
188
335
|
name:
|
|
@@ -195,7 +342,8 @@ export function repeatArraysFrom(features: FeatureLike[]): RepeatArray[] {
|
|
|
195
342
|
end,
|
|
196
343
|
unit,
|
|
197
344
|
motif,
|
|
198
|
-
calls: callsOf(f),
|
|
345
|
+
calls: callsOf(f, alleles, end - start),
|
|
346
|
+
...(units ? { units } : {}),
|
|
199
347
|
})
|
|
200
348
|
}
|
|
201
349
|
return arrays.sort((a, b) => a.start - b.start)
|
|
@@ -125,3 +125,21 @@ test('the threshold falls in a gap the ABCA7 cohort leaves empty', () => {
|
|
|
125
125
|
uncalled: 0,
|
|
126
126
|
})
|
|
127
127
|
})
|
|
128
|
+
|
|
129
|
+
// A phased genotype's k-th allele is PanSN haplotype k, so two walks of one
|
|
130
|
+
// length with different runs still take their own; a phased allele whose
|
|
131
|
+
// haplotype the cut lacks goes to no other walk.
|
|
132
|
+
test('a phased allele pairs with its own haplotype, not by length', () => {
|
|
133
|
+
const hap = (haplotype: number, bp: number) => ({
|
|
134
|
+
...walk('HG1', bp),
|
|
135
|
+
haplotype,
|
|
136
|
+
})
|
|
137
|
+
const rows = withCalls([hap(1, 100), hap(2, 100)], {
|
|
138
|
+
HG1: calls({ bp: 100, haplotype: 2 }, { bp: 100, haplotype: 1 }),
|
|
139
|
+
})
|
|
140
|
+
expect(rows.map(r => r.call?.haplotype)).toEqual([1, 2])
|
|
141
|
+
const lone = withCalls([hap(1, 100)], {
|
|
142
|
+
HG1: calls({ bp: 100, haplotype: 2 }),
|
|
143
|
+
})
|
|
144
|
+
expect(lone[0]!.call).toBeUndefined()
|
|
145
|
+
})
|
|
@@ -67,20 +67,38 @@ function byLength<T>(items: T[], bp: (item: T) => number) {
|
|
|
67
67
|
|
|
68
68
|
const backed = (call: RepeatCall) => call.spanningReads !== 0
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
function scored(row: { bp: number; complete: boolean }, call: RepeatCall) {
|
|
71
|
+
return {
|
|
72
|
+
...call,
|
|
73
|
+
agrees: row.complete && backed(call) ? agrees(row.bp, call.bp) : undefined,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A phased genotype names its walk's haplotype outright. The rest go by
|
|
78
|
+
// length: the walks that span the array against the alleles reads spanned,
|
|
79
|
+
// then everything else against what those two left. That decides the
|
|
80
|
+
// ambiguous cases on merit: which walk carries the one allele a lone spanning
|
|
81
|
+
// read supports, and which allele a walk that stops inside the array is drawn
|
|
82
|
+
// against.
|
|
83
|
+
function pairSample<
|
|
84
|
+
R extends { bp: number; complete: boolean; haplotype?: number },
|
|
85
|
+
>(rows: R[], calls: RepeatCall[]) {
|
|
78
86
|
const out = new Map<number, WalkCall>()
|
|
79
87
|
const taken = new Set<RepeatCall>()
|
|
88
|
+
rows.forEach((row, i) => {
|
|
89
|
+
const call = calls.find(
|
|
90
|
+
c => c.haplotype !== undefined && c.haplotype === row.haplotype,
|
|
91
|
+
)
|
|
92
|
+
if (call) {
|
|
93
|
+
taken.add(call)
|
|
94
|
+
out.set(i, scored(row, call))
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
const unphased = calls.filter(call => call.haplotype === undefined)
|
|
80
98
|
const ranked = byLength(rows, r => r.bp)
|
|
81
99
|
const stages = [
|
|
82
|
-
[ranked.filter(r => r.item.complete),
|
|
83
|
-
[ranked,
|
|
100
|
+
[ranked.filter(r => r.item.complete), unphased.filter(backed)],
|
|
101
|
+
[ranked, unphased],
|
|
84
102
|
] as const
|
|
85
103
|
for (const [pool, group] of stages) {
|
|
86
104
|
const free = pool.filter(r => !out.has(r.i))
|
|
@@ -96,13 +114,7 @@ function pairSample<R extends { bp: number; complete: boolean }>(
|
|
|
96
114
|
const row = free[k]!
|
|
97
115
|
const call = open[pick]!.item
|
|
98
116
|
taken.add(call)
|
|
99
|
-
out.set(row.i,
|
|
100
|
-
...call,
|
|
101
|
-
agrees:
|
|
102
|
-
row.item.complete && backed(call)
|
|
103
|
-
? agrees(row.item.bp, call.bp)
|
|
104
|
-
: undefined,
|
|
105
|
-
})
|
|
117
|
+
out.set(row.i, scored(row.item, call))
|
|
106
118
|
}
|
|
107
119
|
})
|
|
108
120
|
}
|
|
@@ -110,7 +122,12 @@ function pairSample<R extends { bp: number; complete: boolean }>(
|
|
|
110
122
|
}
|
|
111
123
|
|
|
112
124
|
export function withCalls<
|
|
113
|
-
R extends {
|
|
125
|
+
R extends {
|
|
126
|
+
sample: string
|
|
127
|
+
bp: number
|
|
128
|
+
complete: boolean
|
|
129
|
+
haplotype?: number
|
|
130
|
+
},
|
|
114
131
|
>(rows: R[], calls: Record<string, RepeatCall[]> | undefined) {
|
|
115
132
|
if (!calls) {
|
|
116
133
|
return rows as (R & { call?: WalkCall })[]
|
|
@@ -9,17 +9,20 @@ import {
|
|
|
9
9
|
createBaseTrackConfig,
|
|
10
10
|
createBaseTrackModel,
|
|
11
11
|
} from '@jbrowse/core/pluggableElementTypes/models'
|
|
12
|
+
import { LAUNCH_LABEL } from '@jbrowse/core/ui'
|
|
12
13
|
import { getSnapshot, types } from '@jbrowse/mobx-state-tree'
|
|
13
14
|
import { linearGenomeViewStateModelFactory } from '@jbrowse/plugin-linear-genome-view'
|
|
14
15
|
|
|
15
16
|
import LinearGraphDisplayF from './index'
|
|
16
17
|
import GbzBaseSyntenyAdapterF from '../GbzBaseSyntenyAdapter/index'
|
|
18
|
+
import graphGenomeViewModel from '../GraphGenomeView/viewModel'
|
|
17
19
|
import GraphTrackF from '../GraphTrack/index'
|
|
18
20
|
import RgfaTabixAdapterF from '../RgfaTabixAdapter/index'
|
|
19
21
|
|
|
20
22
|
import type { LinearGraphDisplayModel } from './model'
|
|
21
23
|
import type { SubgraphRegion, SubgraphTier } from '../GetSubgraph'
|
|
22
24
|
import type { Renderer } from '@jbrowse/bandage-core/renderer/types'
|
|
25
|
+
import type { MenuItem } from '@jbrowse/core/ui'
|
|
23
26
|
|
|
24
27
|
const REF = 'chr1'
|
|
25
28
|
const ASM = 'hg38'
|
|
@@ -31,14 +34,23 @@ const SETTLE_MS = 700
|
|
|
31
34
|
|
|
32
35
|
// A backbone of `step`-long segments with a small allele bubble on every
|
|
33
36
|
// second one; past 1.5 Mb the fine tier's bubbles run eight ranks deep, so a
|
|
34
|
-
// re-cut there has more rows than one before it.
|
|
35
|
-
|
|
37
|
+
// re-cut there has more rows than one before it. `paths` walks the reference
|
|
38
|
+
// and one haplotype through every bubble, for the tube map.
|
|
39
|
+
function syntheticGraph(
|
|
40
|
+
tier: SubgraphTier,
|
|
41
|
+
region: SubgraphRegion,
|
|
42
|
+
paths = false,
|
|
43
|
+
) {
|
|
36
44
|
const step = tier === 'fine' ? 10_000 : 250_000
|
|
37
45
|
const lines = ['H\tVN:Z:1.0']
|
|
46
|
+
const ref: string[] = []
|
|
47
|
+
const alt: string[] = []
|
|
38
48
|
const first = Math.floor(region.start / step)
|
|
39
49
|
const last = Math.ceil(region.end / step)
|
|
40
50
|
for (let k = first; k < last; k++) {
|
|
41
51
|
const id = `${tier[0]}${k}`
|
|
52
|
+
ref.push(`${id}+`)
|
|
53
|
+
alt.push(`${id}+`)
|
|
42
54
|
lines.push(
|
|
43
55
|
`S\t${id}\t*\tLN:i:${step}\tSN:Z:${REF}\tSO:i:${k * step}\tSR:i:0`,
|
|
44
56
|
)
|
|
@@ -50,6 +62,7 @@ function syntheticGraph(tier: SubgraphTier, region: SubgraphRegion) {
|
|
|
50
62
|
let from = id
|
|
51
63
|
for (let rank = 1; rank <= deep; rank++) {
|
|
52
64
|
const allele = `${id}r${rank}`
|
|
65
|
+
alt.push(`${allele}+`)
|
|
53
66
|
lines.push(
|
|
54
67
|
`S\t${allele}\t*\tLN:i:100\tSN:Z:HG${rank}#1#${REF}\tSO:i:${k * step}\tSR:i:${rank}`,
|
|
55
68
|
`L\t${from}\t+\t${allele}\t+\t0M`,
|
|
@@ -59,6 +72,9 @@ function syntheticGraph(tier: SubgraphTier, region: SubgraphRegion) {
|
|
|
59
72
|
lines.push(`L\t${from}\t+\t${tier[0]}${k + 1}\t+\t0M`)
|
|
60
73
|
}
|
|
61
74
|
}
|
|
75
|
+
if (paths) {
|
|
76
|
+
lines.push(`P\t${REF}\t${ref.join(',')}\t*`, `P\tHG1\t${alt.join(',')}\t*`)
|
|
77
|
+
}
|
|
62
78
|
return lines.join('\n')
|
|
63
79
|
}
|
|
64
80
|
|
|
@@ -94,7 +110,7 @@ function fakeRenderer() {
|
|
|
94
110
|
|
|
95
111
|
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
96
112
|
|
|
97
|
-
function createEnvironment({ tiered = true } = {}) {
|
|
113
|
+
function createEnvironment({ tiered = true, paths = false } = {}) {
|
|
98
114
|
console.warn = vi.fn()
|
|
99
115
|
const pluginManager = new PluginManager()
|
|
100
116
|
RgfaTabixAdapterF(pluginManager)
|
|
@@ -208,6 +224,7 @@ function createEnvironment({ tiered = true } = {}) {
|
|
|
208
224
|
|
|
209
225
|
const cuts: Cut[] = []
|
|
210
226
|
const errors: string[] = []
|
|
227
|
+
const addedViews: [string, unknown][] = []
|
|
211
228
|
// while set, a cut waits for the test to answer it
|
|
212
229
|
let held: ((answer: () => void) => void) | undefined
|
|
213
230
|
const signals: AbortSignal[] = []
|
|
@@ -235,7 +252,7 @@ function createEnvironment({ tiered = true } = {}) {
|
|
|
235
252
|
if (args.signal) {
|
|
236
253
|
signals.push(args.signal)
|
|
237
254
|
}
|
|
238
|
-
const gfa = syntheticGraph(cut.tier, cut.region)
|
|
255
|
+
const gfa = syntheticGraph(cut.tier, cut.region, paths)
|
|
239
256
|
const hold = held
|
|
240
257
|
return hold
|
|
241
258
|
? new Promise<string>(resolve => {
|
|
@@ -295,6 +312,9 @@ function createEnvironment({ tiered = true } = {}) {
|
|
|
295
312
|
errors.push(message)
|
|
296
313
|
},
|
|
297
314
|
queueDialog() {},
|
|
315
|
+
addView(type: string, snapshot: unknown) {
|
|
316
|
+
addedViews.push([type, snapshot])
|
|
317
|
+
},
|
|
298
318
|
}))
|
|
299
319
|
|
|
300
320
|
const session = Session.create({ configuration: {} }, { pluginManager })
|
|
@@ -303,7 +323,16 @@ function createEnvironment({ tiered = true } = {}) {
|
|
|
303
323
|
)
|
|
304
324
|
view.setWidth(WIDTH_PX)
|
|
305
325
|
view.setDisplayedRegions(assemblyRegions)
|
|
306
|
-
return {
|
|
326
|
+
return {
|
|
327
|
+
session,
|
|
328
|
+
view,
|
|
329
|
+
cuts,
|
|
330
|
+
errors,
|
|
331
|
+
addedViews,
|
|
332
|
+
holdCuts,
|
|
333
|
+
signals,
|
|
334
|
+
rpcCall,
|
|
335
|
+
}
|
|
307
336
|
}
|
|
308
337
|
|
|
309
338
|
function lgvX(view: { bpPerPx: number; offsetPx: number }, bp: number) {
|
|
@@ -318,8 +347,9 @@ async function shownGraph({
|
|
|
318
347
|
windowStart = 1_000_000,
|
|
319
348
|
windowBp = 60_000,
|
|
320
349
|
tiered = true,
|
|
350
|
+
paths = false,
|
|
321
351
|
} = {}) {
|
|
322
|
-
const env = createEnvironment({ tiered })
|
|
352
|
+
const env = createEnvironment({ tiered, paths })
|
|
323
353
|
const { view } = env
|
|
324
354
|
view.zoomTo(windowBp / WIDTH_PX)
|
|
325
355
|
view.scrollTo(windowStart / view.bpPerPx)
|
|
@@ -477,6 +507,75 @@ test('a restored session cuts the region it saved', async () => {
|
|
|
477
507
|
expect(cuts[0]!.region).toEqual(saved)
|
|
478
508
|
})
|
|
479
509
|
|
|
510
|
+
test('a tube map on its own axis ties each reference box to its bp in the linear view', async () => {
|
|
511
|
+
const { view, pane } = await shownGraph({ paths: true })
|
|
512
|
+
await pane.switchLayout('tubemap')
|
|
513
|
+
await wait(SETTLE_MS)
|
|
514
|
+
expect(pane.hostPlacesX).toBe(false)
|
|
515
|
+
const nodes = new Map(pane.tubeMapReferenceNodes!.map(n => [n.node, n]))
|
|
516
|
+
const frame = pane.tubeMapFrame!
|
|
517
|
+
const connectors = pane.tubeMapConnectors
|
|
518
|
+
expect(connectors.length).toBeGreaterThan(3)
|
|
519
|
+
for (const c of connectors) {
|
|
520
|
+
const n = nodes.get(c.node)!
|
|
521
|
+
expect(c.top0).toBeCloseTo(lgvX(view, n.bp0), 6)
|
|
522
|
+
expect(c.top1).toBeCloseTo(lgvX(view, n.bp1), 6)
|
|
523
|
+
expect(c.bottom0).toBeCloseTo(frame.x(n.x0), 6)
|
|
524
|
+
expect(c.bottom1).toBeCloseTo(frame.x(n.x1), 6)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const c = connectors[1]!
|
|
528
|
+
const zoneBottom = pane.connectorZoneBottom
|
|
529
|
+
expect(zoneBottom).toBeGreaterThan(0)
|
|
530
|
+
const mid = (c.top0 + c.top1 + c.bottom0 + c.bottom1) / 4
|
|
531
|
+
expect(pane.tubeMapNodeAt(mid, zoneBottom / 2)).toBe(c.node)
|
|
532
|
+
|
|
533
|
+
// a frame of a pan moves the tops with the linear view, the boxes stay
|
|
534
|
+
view.horizontalScroll(50)
|
|
535
|
+
expect(pane.tubeMapConnectors[1]).toEqual({
|
|
536
|
+
...c,
|
|
537
|
+
top0: c.top0 - 50,
|
|
538
|
+
top1: c.top1 - 50,
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
// on the reference axis the boxes already sit on their bp
|
|
542
|
+
await pane.switchLayout('tubemapref')
|
|
543
|
+
expect(pane.tubeMapConnectors).toEqual([])
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
test("a folded tube map draws the cut's reference as merged nodes, and the rest as ticks or routes", async () => {
|
|
547
|
+
const { pane } = await shownGraph({ paths: true })
|
|
548
|
+
const labels = () =>
|
|
549
|
+
pane.trackMenuItems().map(item => ('label' in item ? item.label : ''))
|
|
550
|
+
expect(labels()).not.toContain('Fold variants')
|
|
551
|
+
await pane.switchLayout('tubemap')
|
|
552
|
+
await wait(SETTLE_MS)
|
|
553
|
+
expect(labels()).toContain('Fold variants')
|
|
554
|
+
expect(pane.drawnGraph).toBe(pane.graph)
|
|
555
|
+
expect(pane.tubeMapKeys.foldBp).toBeUndefined()
|
|
556
|
+
|
|
557
|
+
// the haplotype's three 100 bp insertions fold under 1 kb
|
|
558
|
+
pane.setTubeMapFold(1000)
|
|
559
|
+
await pane.recomputeLayout()
|
|
560
|
+
const [merged] = pane.drawnGraph!.nodes
|
|
561
|
+
expect(pane.drawnGraph!.nodes).toHaveLength(1)
|
|
562
|
+
expect(merged!.stable).toMatchObject({ start: 1_000_000, rank: 0 })
|
|
563
|
+
expect(merged!.length).toBe(60_000)
|
|
564
|
+
expect(pane.nodeById!.get(merged!.id)).toBe(merged)
|
|
565
|
+
expect(pane.tubeMapDeviations).toHaveLength(3)
|
|
566
|
+
expect(pane.tubeMapKeys.foldBp).toBe(1000)
|
|
567
|
+
expect(pane.tubeMapConnectors.map(c => c.node)).toEqual([merged!.id])
|
|
568
|
+
|
|
569
|
+
// and stand as their own nodes under 50 bp
|
|
570
|
+
pane.setTubeMapFold(50)
|
|
571
|
+
await pane.recomputeLayout()
|
|
572
|
+
const nodes = pane.drawnGraph!.nodes
|
|
573
|
+
expect(nodes.filter(n => n.stable?.rank === 0)).toHaveLength(4)
|
|
574
|
+
expect(nodes.filter(n => n.stable?.rank === 1)).toHaveLength(3)
|
|
575
|
+
expect(pane.tubeMapDeviations).toEqual([])
|
|
576
|
+
expect(pane.tubeMapKeys.foldBp).toBeUndefined()
|
|
577
|
+
})
|
|
578
|
+
|
|
480
579
|
test('a layout whose x is not reference bp draws its own viewport of the window alone, and still re-cuts', async () => {
|
|
481
580
|
const { view, pane, cuts } = await shownGraph()
|
|
482
581
|
await pane.switchLayout('force')
|
|
@@ -496,6 +595,15 @@ test('a layout whose x is not reference bp draws its own viewport of the window
|
|
|
496
595
|
expect(graphX(pane, 2_030_000)).toBeCloseTo(lgvX(view, 2_030_000), 6)
|
|
497
596
|
})
|
|
498
597
|
|
|
598
|
+
test('walk rows fit their own bars in the track, since a walk can be longer than the window', async () => {
|
|
599
|
+
const { pane } = await shownGraph({ paths: true })
|
|
600
|
+
await pane.switchLayout('walkrows')
|
|
601
|
+
await wait(SETTLE_MS)
|
|
602
|
+
expect(pane.layoutResult?.referenceAxis).toBe(true)
|
|
603
|
+
expect(pane.hostPlacesX).toBe(false)
|
|
604
|
+
expect(pane.viewportOwner).toBe('fit')
|
|
605
|
+
})
|
|
606
|
+
|
|
499
607
|
test('a launch in the force layout cuts the window alone', async () => {
|
|
500
608
|
const { view, cuts } = createEnvironment()
|
|
501
609
|
view.zoomTo(60_000 / WIDTH_PX)
|
|
@@ -623,3 +731,54 @@ test('the track menu offers the layouts, colours and the settings dialog', async
|
|
|
623
731
|
)
|
|
624
732
|
expect(labels).not.toContain('Zoom in')
|
|
625
733
|
})
|
|
734
|
+
|
|
735
|
+
function graphViewItem(display: LinearGraphDisplayModel) {
|
|
736
|
+
const launch = display
|
|
737
|
+
.trackMenuItems()
|
|
738
|
+
.find(item => 'label' in item && item.label === LAUNCH_LABEL) as
|
|
739
|
+
{ subMenu: MenuItem[] } | undefined
|
|
740
|
+
return launch?.subMenu.find(
|
|
741
|
+
item => 'label' in item && item.label === 'Graph genome view',
|
|
742
|
+
) as { disabled?: boolean; onClick: () => void } | undefined
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
test('the track menu opens the window on screen in a graph genome view, drawn as the track draws it', async () => {
|
|
746
|
+
const { display, addedViews } = await shownGraph()
|
|
747
|
+
display.setLayoutMode('tubemapref')
|
|
748
|
+
const item = graphViewItem(display)!
|
|
749
|
+
expect(item.disabled).toBe(false)
|
|
750
|
+
|
|
751
|
+
item.onClick()
|
|
752
|
+
expect(addedViews).toHaveLength(1)
|
|
753
|
+
const [type, spec] = addedViews[0]!
|
|
754
|
+
expect(type).toBe('GraphGenomeView')
|
|
755
|
+
expect(spec).toMatchObject({
|
|
756
|
+
loadedTrackId: 'graph',
|
|
757
|
+
loadedRegion: { refName: REF, assemblyName: ASM },
|
|
758
|
+
layoutMode: 'tubemapref',
|
|
759
|
+
subgraphContext: display.subgraphContext,
|
|
760
|
+
})
|
|
761
|
+
const { loadedRegion } = spec as { loadedRegion: SubgraphRegion }
|
|
762
|
+
expect(loadedRegion.start).toBeCloseTo(1_000_000, -1)
|
|
763
|
+
expect(loadedRegion.end).toBeCloseTo(1_060_000, -1)
|
|
764
|
+
expect(display.cutRegion!.start).toBeLessThan(loadedRegion.start)
|
|
765
|
+
expect(display.cutRegion!.end).toBeGreaterThan(loadedRegion.end)
|
|
766
|
+
expect(() =>
|
|
767
|
+
graphGenomeViewModel().create({
|
|
768
|
+
type: 'GraphGenomeView',
|
|
769
|
+
...(spec as object),
|
|
770
|
+
}),
|
|
771
|
+
).not.toThrow()
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
test('zoomed out to the coarse tier, the graph genome view launch is disabled', async () => {
|
|
775
|
+
const { view, display, addedViews } = await shownGraph()
|
|
776
|
+
view.zoomTo(3_000_000 / WIDTH_PX)
|
|
777
|
+
await wait(SETTLE_MS)
|
|
778
|
+
expect(display.cutTier).toBe('coarse')
|
|
779
|
+
|
|
780
|
+
const item = graphViewItem(display)!
|
|
781
|
+
expect(item.disabled).toBe(true)
|
|
782
|
+
item.onClick()
|
|
783
|
+
expect(addedViews).toHaveLength(0)
|
|
784
|
+
})
|