jbrowse-plugin-graphgenomeviewer 4.0.12 → 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.
Files changed (61) hide show
  1. package/README.md +21 -5
  2. package/dist/chunks/GbzBaseSyntenyAdapter-Y5NBXK7P.js +8 -0
  3. package/dist/chunks/{GbzBaseSyntenyAdapter-5ZQ2IPHJ.js.map → GbzBaseSyntenyAdapter-Y5NBXK7P.js.map} +3 -3
  4. package/dist/chunks/{GraphGenomeView-SRR7EJFX.js → GraphGenomeView-TTQEJJQD.js} +2 -2
  5. package/dist/chunks/{GraphGenomeView-SRR7EJFX.js.map → GraphGenomeView-TTQEJJQD.js.map} +1 -1
  6. package/dist/chunks/GraphTrackSettingsDialog-3A2PKZRI.js +4 -0
  7. package/dist/chunks/{GraphTrackSettingsDialog-LZITZ3VD.js.map → GraphTrackSettingsDialog-3A2PKZRI.js.map} +1 -1
  8. package/dist/chunks/LinearGraphDisplay-MJ5XZFE5.js +2 -0
  9. package/dist/chunks/{LinearGraphDisplay-J3KVTK4C.js.map → LinearGraphDisplay-MJ5XZFE5.js.map} +1 -1
  10. package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js → MinigraphBubbleAdapter-APC7JYOM.js} +2 -2
  11. package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js → RgfaTabixAdapter-4E365PXO.js} +2 -2
  12. package/dist/chunks/{chunk-LGGAXAS6.js → chunk-5DP7IXD3.js} +2 -2
  13. package/dist/chunks/chunk-CJ56O2VG.js +2 -0
  14. package/dist/chunks/chunk-CJ56O2VG.js.map +7 -0
  15. package/dist/chunks/chunk-EFVAQ2KM.js +2 -0
  16. package/dist/chunks/chunk-EFVAQ2KM.js.map +7 -0
  17. package/dist/chunks/chunk-IFRDWMQ2.js +2 -0
  18. package/dist/chunks/chunk-IFRDWMQ2.js.map +7 -0
  19. package/dist/chunks/chunk-JAQ2RQAE.js +2 -0
  20. package/dist/chunks/{chunk-QRVREF7H.js.map → chunk-JAQ2RQAE.js.map} +4 -4
  21. package/dist/chunks/{chunk-WETM2CD7.js → chunk-LA7MQS33.js} +2 -2
  22. package/dist/chunks/chunk-M4YEAXSR.js +2 -0
  23. package/dist/chunks/chunk-M4YEAXSR.js.map +7 -0
  24. package/dist/chunks/chunk-XDOIZTY6.js +4 -0
  25. package/dist/chunks/chunk-XDOIZTY6.js.map +7 -0
  26. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  27. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  28. package/package.json +3 -3
  29. package/src/GraphGenomeView/components/GraphCanvas.tsx +4 -11
  30. package/src/GraphGenomeView/components/TubeMapOverlay.tsx +117 -4
  31. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +261 -44
  32. package/src/GraphGenomeView/components/referenceRampCss.test.ts +27 -0
  33. package/src/GraphGenomeView/components/referenceRampCss.ts +39 -0
  34. package/src/GraphGenomeView/genes/genes.test.ts +9 -2
  35. package/src/GraphGenomeView/host.test.ts +25 -2
  36. package/src/GraphGenomeView/host.ts +9 -7
  37. package/src/GraphGenomeView/model.test.ts +383 -4
  38. package/src/GraphGenomeView/model.ts +413 -88
  39. package/src/GraphGenomeView/repeats/repeatFeatures.test.ts +75 -0
  40. package/src/GraphGenomeView/repeats/repeatFeatures.ts +156 -8
  41. package/src/GraphGenomeView/repeats/walkCalls.test.ts +18 -0
  42. package/src/GraphGenomeView/repeats/walkCalls.ts +35 -18
  43. package/src/GraphGenomeView/subgraphLoad.test.ts +1 -0
  44. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +165 -6
  45. package/src/LinearGraphDisplay/model.ts +61 -1
  46. package/src/gaf/gafFile.test.ts +18 -0
  47. package/src/version.ts +1 -1
  48. package/dist/chunks/GbzBaseSyntenyAdapter-5ZQ2IPHJ.js +0 -8
  49. package/dist/chunks/GraphTrackSettingsDialog-LZITZ3VD.js +0 -4
  50. package/dist/chunks/LinearGraphDisplay-J3KVTK4C.js +0 -2
  51. package/dist/chunks/chunk-ADEBM7AA.js +0 -2
  52. package/dist/chunks/chunk-ADEBM7AA.js.map +0 -7
  53. package/dist/chunks/chunk-J7CABMMI.js +0 -2
  54. package/dist/chunks/chunk-J7CABMMI.js.map +0 -7
  55. package/dist/chunks/chunk-LBHU3DHN.js +0 -4
  56. package/dist/chunks/chunk-LBHU3DHN.js.map +0 -7
  57. package/dist/chunks/chunk-QRVREF7H.js +0 -2
  58. /package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js.map → MinigraphBubbleAdapter-APC7JYOM.js.map} +0 -0
  59. /package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js.map → RgfaTabixAdapter-4E365PXO.js.map} +0 -0
  60. /package/dist/chunks/{chunk-LGGAXAS6.js.map → chunk-5DP7IXD3.js.map} +0 -0
  61. /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 catalogue.
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 callsOf(f: FeatureLike) {
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 alleles = numbers(fields.AL).flatMap((bp, i) =>
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
- if (alleles.length > 0) {
168
- called[sample] = alleles
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 start = field(f, 'start') as number
178
- const end = field(f, 'end') as number
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
- // The walks that span the array against the alleles reads spanned, then
71
- // everything else against what those two left. It decides the ambiguous cases
72
- // on merit: which walk carries the one allele a lone spanning read supports,
73
- // and which allele a walk that stops inside the array is drawn against.
74
- function pairSample<R extends { bp: number; complete: boolean }>(
75
- rows: R[],
76
- calls: RepeatCall[],
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), calls.filter(backed)],
83
- [ranked, calls],
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 { sample: string; bp: number; complete: boolean },
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 })[]
@@ -4,6 +4,7 @@ const mockRpcCall = vi.fn()
4
4
  const mockSession = {
5
5
  tracks: [] as Record<string, unknown>[],
6
6
  rpcManager: { call: mockRpcCall },
7
+ assemblyManager: { has: () => false },
7
8
  }
8
9
 
9
10
  vi.mock('@jbrowse/core/util', () => ({
@@ -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
- function syntheticGraph(tier: SubgraphTier, region: SubgraphRegion) {
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 { session, view, cuts, errors, holdCuts, signals, rpcCall }
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
+ })