jbrowse-plugin-graphgenomeviewer 3.0.4 → 3.1.0
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 +29 -2
- package/dist/chunks/GbzBaseSyntenyAdapter-MIOUYHOF.js +8 -0
- package/dist/chunks/GbzBaseSyntenyAdapter-MIOUYHOF.js.map +7 -0
- package/dist/chunks/GraphGenomeView-TM7WYDLH.js +14 -0
- package/dist/chunks/GraphGenomeView-TM7WYDLH.js.map +7 -0
- package/dist/chunks/MinigraphBubbleAdapter-A7SBF2XY.js +2 -0
- package/dist/chunks/MinigraphBubbleAdapter-A7SBF2XY.js.map +7 -0
- package/dist/chunks/RgfaTabixAdapter-7ZGKJJYW.js +3 -0
- package/dist/chunks/RgfaTabixAdapter-7ZGKJJYW.js.map +7 -0
- package/dist/chunks/{chunk-X3X74OGB.js → chunk-7T2HTRP3.js} +2 -2
- package/dist/chunks/{chunk-X3X74OGB.js.map → chunk-7T2HTRP3.js.map} +3 -3
- package/dist/chunks/{chunk-JCG7RLMB.js → chunk-HOM2Y6XS.js} +2 -2
- package/dist/chunks/chunk-HOM2Y6XS.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 +21 -22
- package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.test.ts +69 -0
- package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.ts +13 -10
- package/src/GetSubgraph.ts +5 -1
- package/src/GraphGenomeView/components/FollowControl.test.tsx +69 -0
- package/src/GraphGenomeView/components/FollowControl.tsx +42 -0
- package/src/GraphGenomeView/components/GraphStats.tsx +1 -0
- package/src/GraphGenomeView/components/GraphToolbar.tsx +2 -0
- package/src/GraphGenomeView/follow.test.ts +624 -0
- package/src/GraphGenomeView/follow.ts +129 -0
- package/src/GraphGenomeView/layout/sampleRowLayout.test.ts +16 -0
- package/src/GraphGenomeView/layout/sampleRowLayout.ts +13 -4
- package/src/GraphGenomeView/layoutModes.ts +3 -1
- package/src/GraphGenomeView/model.ts +361 -55
- package/src/GraphGenomeView/types.ts +4 -0
- package/src/MinigraphBubbleAdapter/MinigraphBubbleAdapter.ts +1 -1
- package/src/RgfaTabixAdapter/RgfaTabixAdapter.test.ts +73 -0
- package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +48 -16
- package/src/RgfaTabixAdapter/configSchema.ts +70 -3
- package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.links.bed.gz +0 -0
- package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.links.bed.gz.tbi +0 -0
- package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.segs.bed.gz +0 -0
- package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.segs.bed.gz.tbi +0 -0
- package/src/launchSubgraph/index.ts +12 -6
- package/src/launchSubgraph/launchSubgraph.test.ts +89 -3
- package/src/launchSubgraph/launchSubgraphView.ts +14 -6
- package/src/launchSubgraph/linearViewMenuItems.ts +3 -0
- package/src/launchSubgraph/subgraphMenuItems.ts +7 -0
- package/src/launchSubgraph/testEnv.ts +2 -0
- package/src/panSNTabix.ts +6 -5
- package/src/synteny/ComparativeAdapterBase.ts +13 -10
- package/src/synteny/clipFeatureToRegion.test.ts +41 -1
- package/src/synteny/clipFeatureToRegion.ts +22 -5
- package/src/version.ts +1 -1
- package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js +0 -8
- package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js.map +0 -7
- package/dist/chunks/GraphGenomeView-UD5WCV3Q.js +0 -14
- package/dist/chunks/GraphGenomeView-UD5WCV3Q.js.map +0 -7
- package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js +0 -2
- package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js.map +0 -7
- package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js +0 -3
- package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js.map +0 -7
- package/dist/chunks/chunk-JCG7RLMB.js.map +0 -7
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { observable, observableRef } from 'mobx'
|
|
2
|
+
|
|
3
|
+
import { followCut } from './follow'
|
|
4
|
+
import stateModelFactory from './model'
|
|
5
|
+
|
|
6
|
+
import type { SubgraphTier } from '../GetSubgraph'
|
|
7
|
+
import type { FollowWindow } from './follow'
|
|
8
|
+
import type { Renderer } from './renderer/types'
|
|
9
|
+
import type { SubgraphRegion } from '../launchSubgraph/launchSubgraphView'
|
|
10
|
+
|
|
11
|
+
const mockRpcCall = vi.fn()
|
|
12
|
+
const mockSession = {
|
|
13
|
+
tracks: [] as Record<string, unknown>[],
|
|
14
|
+
rpcManager: { call: mockRpcCall },
|
|
15
|
+
views: [] as unknown[],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
vi.mock('@jbrowse/core/util', () => ({
|
|
19
|
+
getSession: () => mockSession,
|
|
20
|
+
isSessionModelWithWidgets: () => false,
|
|
21
|
+
parseLocString: () => ({}),
|
|
22
|
+
getEnv: () => ({}),
|
|
23
|
+
useWidthSetter: () => {},
|
|
24
|
+
measureText: () => 0,
|
|
25
|
+
IntervalTree: class {},
|
|
26
|
+
getSnapshot: () => ({}),
|
|
27
|
+
applySnapshot: () => {},
|
|
28
|
+
objectHash: () => '',
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
vi.mock(import('@jbrowse/core/configuration'), async importOriginal => ({
|
|
32
|
+
...(await importOriginal()),
|
|
33
|
+
readConfObject: vi.fn(
|
|
34
|
+
(obj: Record<string, unknown>, key: string) => obj[key],
|
|
35
|
+
),
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
const REF = 'chr1'
|
|
39
|
+
const ASM = 'hg38'
|
|
40
|
+
const CONTIG = 10_000_000
|
|
41
|
+
const WIDTH_PX = 1000
|
|
42
|
+
// 1 Mb across the 1000 px view
|
|
43
|
+
const COARSE_ABOVE_BP_PER_PX = 1000
|
|
44
|
+
|
|
45
|
+
function graphTrack(adapter: Record<string, unknown>) {
|
|
46
|
+
return { trackId: 'graph', assemblyNames: [ASM], adapter }
|
|
47
|
+
}
|
|
48
|
+
const TIERED = graphTrack({
|
|
49
|
+
type: 'RgfaTabixAdapter',
|
|
50
|
+
uri: 'graph',
|
|
51
|
+
coarse: { uri: 'graph.tier10000', aboveBpPerPx: COARSE_ABOVE_BP_PER_PX },
|
|
52
|
+
})
|
|
53
|
+
const UNTIERED = graphTrack({ type: 'RgfaTabixAdapter', uri: 'graph' })
|
|
54
|
+
|
|
55
|
+
// One backbone segment per `step` bp, ids fixed by position so overlapping cuts
|
|
56
|
+
// name the same segment the same way, and an allele over every other one. Past
|
|
57
|
+
// 1.5 Mb the fine tier's alleles nest eight ranks deep, so a cut there has
|
|
58
|
+
// more rows than one before it.
|
|
59
|
+
function syntheticGraph(tier: SubgraphTier, region: SubgraphRegion) {
|
|
60
|
+
const step = tier === 'fine' ? 10_000 : 250_000
|
|
61
|
+
const lines = ['H\tVN:Z:1.0']
|
|
62
|
+
const first = Math.floor(region.start / step)
|
|
63
|
+
const last = Math.ceil(region.end / step)
|
|
64
|
+
for (let k = first; k < last; k++) {
|
|
65
|
+
const id = `${tier[0]}${k}`
|
|
66
|
+
lines.push(
|
|
67
|
+
`S\t${id}\t*\tLN:i:${step}\tSN:Z:${REF}\tSO:i:${k * step}\tSR:i:0`,
|
|
68
|
+
)
|
|
69
|
+
if (k > first) {
|
|
70
|
+
lines.push(`L\t${tier[0]}${k - 1}\t+\t${id}\t+\t0M`)
|
|
71
|
+
}
|
|
72
|
+
if (k % 2 === 0 && k + 1 < last) {
|
|
73
|
+
const deep = tier === 'fine' && k * step >= 1_500_000 ? 8 : 1
|
|
74
|
+
let from = id
|
|
75
|
+
for (let rank = 1; rank <= deep; rank++) {
|
|
76
|
+
const allele = `${id}r${rank}`
|
|
77
|
+
lines.push(
|
|
78
|
+
`S\t${allele}\t*\tLN:i:100\tSN:Z:HG${rank}#1#${REF}\tSO:i:${k * step}\tSR:i:${rank}`,
|
|
79
|
+
`L\t${from}\t+\t${allele}\t+\t0M`,
|
|
80
|
+
)
|
|
81
|
+
from = allele
|
|
82
|
+
}
|
|
83
|
+
lines.push(`L\t${from}\t+\t${tier[0]}${k + 1}\t+\t0M`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return lines.join('\n')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The fine tier's backbone with one allele per sample per bubble, sized so the
|
|
90
|
+
// samples rank HG1, HG2 by what they carry before 1.15 Mb and HG2, HG3, HG1
|
|
91
|
+
// past it: the order a fresh cut of a window past 1.15 Mb sorts to.
|
|
92
|
+
function samplesGraph(_tier: SubgraphTier, region: SubgraphRegion) {
|
|
93
|
+
const step = 10_000
|
|
94
|
+
const lines = ['H\tVN:Z:1.0']
|
|
95
|
+
const first = Math.floor(region.start / step)
|
|
96
|
+
const last = Math.ceil(region.end / step)
|
|
97
|
+
for (let k = first; k < last; k++) {
|
|
98
|
+
lines.push(
|
|
99
|
+
`S\tf${k}\t*\tLN:i:${step}\tSN:Z:${REF}\tSO:i:${k * step}\tSR:i:0`,
|
|
100
|
+
)
|
|
101
|
+
if (k > first) {
|
|
102
|
+
lines.push(`L\tf${k - 1}\t+\tf${k}\t+\t0M`)
|
|
103
|
+
}
|
|
104
|
+
if (k % 2 === 0 && k + 1 < last) {
|
|
105
|
+
const carried: [string, number][] =
|
|
106
|
+
k * step < 1_150_000
|
|
107
|
+
? [
|
|
108
|
+
['HG1', 1000],
|
|
109
|
+
['HG2', 100],
|
|
110
|
+
]
|
|
111
|
+
: [
|
|
112
|
+
['HG2', 1000],
|
|
113
|
+
['HG3', 500],
|
|
114
|
+
['HG1', 100],
|
|
115
|
+
]
|
|
116
|
+
for (const [sample, bp] of carried) {
|
|
117
|
+
const allele = `f${k}${sample}`
|
|
118
|
+
lines.push(
|
|
119
|
+
`S\t${allele}\t*\tLN:i:${bp}\tSN:Z:${sample}#1#${REF}\tSO:i:${k * step}\tSR:i:1`,
|
|
120
|
+
`L\tf${k}\t+\t${allele}\t+\t0M`,
|
|
121
|
+
`L\t${allele}\t+\tf${k + 1}\t+\t0M`,
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return lines.join('\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface Cut {
|
|
130
|
+
tier: SubgraphTier
|
|
131
|
+
region: SubgraphRegion
|
|
132
|
+
hops: number | undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Reads {
|
|
136
|
+
cuts: Cut[]
|
|
137
|
+
bubbleReads: SubgraphRegion[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const FORCE_LAYOUT = {
|
|
141
|
+
nodePositions: {
|
|
142
|
+
'f100+': [
|
|
143
|
+
{ x: 0, y: 0 },
|
|
144
|
+
{ x: 400, y: 0 },
|
|
145
|
+
],
|
|
146
|
+
'f101+': [
|
|
147
|
+
{ x: 500, y: 300 },
|
|
148
|
+
{ x: 900, y: 300 },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stubSubgraphs({ cuts, bubbleReads }: Reads, graph = syntheticGraph) {
|
|
154
|
+
mockRpcCall.mockImplementation(
|
|
155
|
+
(
|
|
156
|
+
_sid: unknown,
|
|
157
|
+
method: string,
|
|
158
|
+
args: {
|
|
159
|
+
region: SubgraphRegion
|
|
160
|
+
regions?: SubgraphRegion[]
|
|
161
|
+
opts?: { tier?: SubgraphTier; hops?: number }
|
|
162
|
+
},
|
|
163
|
+
) => {
|
|
164
|
+
if (method === 'GraphComputeLayout') {
|
|
165
|
+
return Promise.resolve({ result: FORCE_LAYOUT, duration: 1 })
|
|
166
|
+
}
|
|
167
|
+
if (method === 'CoreGetFeatures') {
|
|
168
|
+
bubbleReads.push(args.regions![0]!)
|
|
169
|
+
return Promise.resolve([])
|
|
170
|
+
}
|
|
171
|
+
if (method !== 'GetSubgraph') {
|
|
172
|
+
return Promise.reject(new Error(`Unexpected RPC: ${method}`))
|
|
173
|
+
}
|
|
174
|
+
const cut = {
|
|
175
|
+
tier: args.opts?.tier ?? 'fine',
|
|
176
|
+
region: args.region,
|
|
177
|
+
hops: args.opts?.hops,
|
|
178
|
+
}
|
|
179
|
+
cuts.push(cut)
|
|
180
|
+
return Promise.resolve(graph(cut.tier, cut.region))
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface Block {
|
|
186
|
+
key: string
|
|
187
|
+
refName: string
|
|
188
|
+
assemblyName: string
|
|
189
|
+
start: number
|
|
190
|
+
end: number
|
|
191
|
+
offsetPx: number
|
|
192
|
+
displayedRegionIndex: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The members of a LinearGenomeView the follow reads, over one displayed
|
|
196
|
+
// region starting at 0, so a bp's px is bp / bpPerPx. `settle` is what the
|
|
197
|
+
// LGV's 500 ms coarse-blocks autorun does.
|
|
198
|
+
function mockLinearView(windowStart: number, windowBp: number) {
|
|
199
|
+
const view = observable(
|
|
200
|
+
{
|
|
201
|
+
id: 'lgv',
|
|
202
|
+
type: 'LinearGenomeView',
|
|
203
|
+
assemblyNames: [ASM],
|
|
204
|
+
initialized: true,
|
|
205
|
+
width: WIDTH_PX,
|
|
206
|
+
windowStartBp: windowStart,
|
|
207
|
+
windowWidthBp: windowBp,
|
|
208
|
+
displayedRegions: [
|
|
209
|
+
{ refName: REF, assemblyName: ASM, start: 0, end: CONTIG },
|
|
210
|
+
],
|
|
211
|
+
coarseDynamicBlocks: [] as Block[],
|
|
212
|
+
get bpPerPx() {
|
|
213
|
+
return this.windowWidthBp / this.width
|
|
214
|
+
},
|
|
215
|
+
get offsetPx() {
|
|
216
|
+
return this.windowStartBp / this.bpPerPx
|
|
217
|
+
},
|
|
218
|
+
get dynamicBlocks() {
|
|
219
|
+
const start = Math.max(0, this.windowStartBp)
|
|
220
|
+
const end = Math.min(CONTIG, this.windowStartBp + this.windowWidthBp)
|
|
221
|
+
return {
|
|
222
|
+
contentBlocks: [
|
|
223
|
+
{
|
|
224
|
+
key: `${REF}:${start}-${end}`,
|
|
225
|
+
refName: REF,
|
|
226
|
+
assemblyName: ASM,
|
|
227
|
+
start,
|
|
228
|
+
end,
|
|
229
|
+
offsetPx: start / this.bpPerPx,
|
|
230
|
+
displayedRegionIndex: 0,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
navToLocString() {},
|
|
236
|
+
horizontalScroll(distancePx: number) {
|
|
237
|
+
this.windowStartBp += distancePx * this.bpPerPx
|
|
238
|
+
return distancePx
|
|
239
|
+
},
|
|
240
|
+
zoomTo(bpPerPx: number, offset = this.width / 2) {
|
|
241
|
+
const anchor = this.windowStartBp + offset * this.bpPerPx
|
|
242
|
+
this.windowWidthBp = bpPerPx * this.width
|
|
243
|
+
this.windowStartBp = anchor - offset * bpPerPx
|
|
244
|
+
return bpPerPx
|
|
245
|
+
},
|
|
246
|
+
settle() {
|
|
247
|
+
this.coarseDynamicBlocks = this.dynamicBlocks.contentBlocks
|
|
248
|
+
},
|
|
249
|
+
panBy(bp: number) {
|
|
250
|
+
this.windowStartBp += bp
|
|
251
|
+
},
|
|
252
|
+
showWidth(bp: number) {
|
|
253
|
+
const center = this.windowStartBp + this.windowWidthBp / 2
|
|
254
|
+
this.windowWidthBp = bp
|
|
255
|
+
this.windowStartBp = center - bp / 2
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
{ coarseDynamicBlocks: observableRef },
|
|
259
|
+
)
|
|
260
|
+
return view
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
type LinearView = ReturnType<typeof mockLinearView>
|
|
264
|
+
|
|
265
|
+
function liveWindow(view: LinearView): FollowWindow {
|
|
266
|
+
const block = view.dynamicBlocks.contentBlocks[0]!
|
|
267
|
+
return {
|
|
268
|
+
refName: REF,
|
|
269
|
+
assemblyName: ASM,
|
|
270
|
+
start: block.start,
|
|
271
|
+
end: block.end,
|
|
272
|
+
bpPerPx: view.bpPerPx,
|
|
273
|
+
span: view.windowWidthBp,
|
|
274
|
+
regionStart: 0,
|
|
275
|
+
regionEnd: CONTIG,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function fakeRenderer() {
|
|
280
|
+
return {
|
|
281
|
+
resize: () => {},
|
|
282
|
+
uploadGeometry: () => {},
|
|
283
|
+
updateTransform: () => {},
|
|
284
|
+
render: () => {},
|
|
285
|
+
setNodeHighlights: () => {},
|
|
286
|
+
setEdgeHighlight: () => {},
|
|
287
|
+
destroy: () => {},
|
|
288
|
+
} as unknown as Renderer
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const flush = () => new Promise(resolve => setTimeout(resolve, 0))
|
|
292
|
+
|
|
293
|
+
// Where a bp lands on screen in each view. Following means these agree.
|
|
294
|
+
function lgvX(view: LinearView, bp: number) {
|
|
295
|
+
return (bp - view.windowStartBp) / view.bpPerPx
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function graphX(model: { scale: number; translateX: number }, bp: number) {
|
|
299
|
+
return bp * model.scale + model.translateX
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function followingModel({
|
|
303
|
+
windowStart = 1_000_000,
|
|
304
|
+
windowBp = 60_000,
|
|
305
|
+
layoutMode = 'auto',
|
|
306
|
+
tiered = true,
|
|
307
|
+
graph = syntheticGraph,
|
|
308
|
+
}: {
|
|
309
|
+
windowStart?: number
|
|
310
|
+
windowBp?: number
|
|
311
|
+
layoutMode?: string
|
|
312
|
+
tiered?: boolean
|
|
313
|
+
graph?: typeof syntheticGraph
|
|
314
|
+
} = {}) {
|
|
315
|
+
const cuts: Cut[] = []
|
|
316
|
+
const bubbleReads: SubgraphRegion[] = []
|
|
317
|
+
stubSubgraphs({ cuts, bubbleReads }, graph)
|
|
318
|
+
const view = mockLinearView(windowStart, windowBp)
|
|
319
|
+
mockSession.views = [view]
|
|
320
|
+
if (!tiered) {
|
|
321
|
+
mockSession.tracks = [UNTIERED]
|
|
322
|
+
}
|
|
323
|
+
const model = stateModelFactory().create({
|
|
324
|
+
type: 'GraphGenomeView',
|
|
325
|
+
layoutMode,
|
|
326
|
+
loadedTrackId: 'graph',
|
|
327
|
+
// a follow under way: the window and its margins
|
|
328
|
+
loadedRegion: followCut(liveWindow(view), Infinity),
|
|
329
|
+
connectedViewId: view.id,
|
|
330
|
+
followLinearView: true,
|
|
331
|
+
} as never)
|
|
332
|
+
// the mount order the app has: the cut lands, the canvas mounts, then the
|
|
333
|
+
// width is measured
|
|
334
|
+
await model.refetchIfNeeded()
|
|
335
|
+
model.startRenderingBackend(fakeRenderer())
|
|
336
|
+
model.setWidth(WIDTH_PX)
|
|
337
|
+
view.settle()
|
|
338
|
+
await flush()
|
|
339
|
+
return { model, view, cuts, bubbleReads }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
mockRpcCall.mockReset()
|
|
344
|
+
mockSession.tracks = [TIERED]
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('a 2 Mb pan in 10 kb steps moves x every step and re-cuts every seventh', async () => {
|
|
348
|
+
const { model, view, cuts } = await followingModel()
|
|
349
|
+
expect(model.followState.active).toBe(true)
|
|
350
|
+
expect(model.viewportOwner).toBe('follow')
|
|
351
|
+
expect(cuts).toHaveLength(1)
|
|
352
|
+
expect(graphX(model, 1_030_000)).toBeCloseTo(lgvX(view, 1_030_000), 6)
|
|
353
|
+
|
|
354
|
+
const selected = 'f103+'
|
|
355
|
+
model.setSelectedNode(selected)
|
|
356
|
+
const paneHeight = model.canvasHeight
|
|
357
|
+
const rowExtents = new Set<number>()
|
|
358
|
+
|
|
359
|
+
const translateXs: number[] = []
|
|
360
|
+
const recutAtStep: number[] = []
|
|
361
|
+
for (let step = 1; step <= 200; step++) {
|
|
362
|
+
view.panBy(10_000)
|
|
363
|
+
// the frame clock: x moved with the window, before anything settled
|
|
364
|
+
translateXs.push(model.translateX)
|
|
365
|
+
const probe = view.windowStartBp + 30_000
|
|
366
|
+
expect(graphX(model, probe)).toBeCloseTo(lgvX(view, probe), 6)
|
|
367
|
+
|
|
368
|
+
const before = cuts.length
|
|
369
|
+
view.settle()
|
|
370
|
+
await flush()
|
|
371
|
+
if (cuts.length > before) {
|
|
372
|
+
recutAtStep.push(step)
|
|
373
|
+
}
|
|
374
|
+
// a re-cut's new layout did not refit x out from under the follow
|
|
375
|
+
expect(graphX(model, probe)).toBeCloseTo(lgvX(view, probe), 6)
|
|
376
|
+
expect(model.canvasHeight).toBe(paneHeight)
|
|
377
|
+
rowExtents.add(model.layoutBounds!.h)
|
|
378
|
+
if (step === 7) {
|
|
379
|
+
expect(model.selectedNode).toBe(selected)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
expect(new Set(translateXs).size).toBe(200)
|
|
384
|
+
// A cut is the window plus 60 kb each side, 180 kb. The window's right edge
|
|
385
|
+
// reaches the cut's after 60 kb (six steps, still inside), so the seventh
|
|
386
|
+
// step re-cuts, and the next cut is centred there: one fetch per 70 kb, 28
|
|
387
|
+
// over 200 steps.
|
|
388
|
+
expect(recutAtStep).toEqual(Array.from({ length: 28 }, (_, i) => (i + 1) * 7))
|
|
389
|
+
expect(model.followRecuts).toBe(28)
|
|
390
|
+
expect(cuts.every(c => c.tier === 'fine')).toBe(true)
|
|
391
|
+
// past 1.5 Mb the rows are eight ranks deep, and the pane held its height
|
|
392
|
+
expect(rowExtents.size).toBeGreaterThan(1)
|
|
393
|
+
// f103 (1.03 Mb) is long out of the last cut, and the selection with it
|
|
394
|
+
expect(model.selectedNode).toBeNull()
|
|
395
|
+
expect(model.error).toBeUndefined()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
test('a re-cut keeps the selection, found again by id', async () => {
|
|
399
|
+
const { model, view } = await followingModel()
|
|
400
|
+
model.setSelectedNode('f103+')
|
|
401
|
+
model.setHoveredEdge(0)
|
|
402
|
+
view.panBy(70_000)
|
|
403
|
+
view.settle()
|
|
404
|
+
await flush()
|
|
405
|
+
expect(model.followRecuts).toBe(1)
|
|
406
|
+
expect(model.nodeById?.has('f103+')).toBe(true)
|
|
407
|
+
expect(model.selectedNode).toBe('f103+')
|
|
408
|
+
// an edge index means nothing in another graph
|
|
409
|
+
expect(model.hoveredEdge).toBeNull()
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('sample rows keep their order across a re-cut, and a fresh cut sorts them', async () => {
|
|
413
|
+
const { model, view } = await followingModel({
|
|
414
|
+
layoutMode: 'samplerows',
|
|
415
|
+
graph: samplesGraph,
|
|
416
|
+
})
|
|
417
|
+
const rows = () => model.rowLabels.slice(1).map(r => r.label)
|
|
418
|
+
expect(model.followState.active).toBe(true)
|
|
419
|
+
expect(rows()).toEqual(['HG1', 'HG2'])
|
|
420
|
+
|
|
421
|
+
view.panBy(200_000)
|
|
422
|
+
view.settle()
|
|
423
|
+
await flush()
|
|
424
|
+
expect(model.followRecuts).toBe(1)
|
|
425
|
+
expect(rows()).toEqual(['HG1', 'HG2', 'HG3'])
|
|
426
|
+
|
|
427
|
+
model.setFollowLinearView(false)
|
|
428
|
+
await model.reloadSubgraph()
|
|
429
|
+
expect(rows()).toEqual(['HG2', 'HG3', 'HG1'])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('zooming out to 3 Mb cuts the coarse tier above the threshold, and back in the fine one', async () => {
|
|
433
|
+
// centred at 5 Mb, so no margin is clamped at a contig end
|
|
434
|
+
const { model, view, cuts, bubbleReads } = await followingModel({
|
|
435
|
+
windowStart: 4_970_000,
|
|
436
|
+
})
|
|
437
|
+
const widths = [120_000, 240_000, 480_000, 960_000, 1_920_000, 3_000_000]
|
|
438
|
+
const tierAt = new Map<number, string>()
|
|
439
|
+
|
|
440
|
+
async function zoomTo(bp: number) {
|
|
441
|
+
view.showWidth(bp)
|
|
442
|
+
const probe = view.windowStartBp + bp / 3
|
|
443
|
+
expect(graphX(model, probe)).toBeCloseTo(lgvX(view, probe), 6)
|
|
444
|
+
const before = cuts.length
|
|
445
|
+
view.settle()
|
|
446
|
+
await flush()
|
|
447
|
+
if (cuts.length > before) {
|
|
448
|
+
tierAt.set(bp, cuts.at(-1)!.tier)
|
|
449
|
+
}
|
|
450
|
+
expect(model.cutTier).toBe(
|
|
451
|
+
bp / WIDTH_PX > COARSE_ABOVE_BP_PER_PX ? 'coarse' : 'fine',
|
|
452
|
+
)
|
|
453
|
+
expect(graphX(model, probe)).toBeCloseTo(lgvX(view, probe), 6)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const bp of widths) {
|
|
457
|
+
await zoomTo(bp)
|
|
458
|
+
}
|
|
459
|
+
// Each cut holds the next doubling's first half: 120 kb and 480 kb land
|
|
460
|
+
// inside the cuts made at 60 kb and 240 kb. 3 Mb lands inside 1.92 Mb's.
|
|
461
|
+
expect([...tierAt]).toEqual([
|
|
462
|
+
[240_000, 'fine'],
|
|
463
|
+
[960_000, 'fine'],
|
|
464
|
+
[1_920_000, 'coarse'],
|
|
465
|
+
])
|
|
466
|
+
// 1.92 Mb plus its margins is 5.76 Mb: past the 5 Mb a fine cut may span,
|
|
467
|
+
// and the coarse tier is not held to it
|
|
468
|
+
const coarseSpan = model.loadedRegion!.end - model.loadedRegion!.start
|
|
469
|
+
expect(coarseSpan).toBeGreaterThan(model.maxRegionBp)
|
|
470
|
+
expect(model.error).toBeUndefined()
|
|
471
|
+
expect(model.graph!.nodes.every(n => n.id.startsWith('c'))).toBe(true)
|
|
472
|
+
// a hop past a coarse cut reaches nothing, so none is asked for
|
|
473
|
+
expect(cuts.filter(c => c.tier === 'coarse').map(c => c.hops)).toEqual([0])
|
|
474
|
+
|
|
475
|
+
tierAt.clear()
|
|
476
|
+
for (const bp of [...widths].reverse().slice(1).concat(60_000)) {
|
|
477
|
+
await zoomTo(bp)
|
|
478
|
+
}
|
|
479
|
+
expect([...tierAt]).toEqual([[960_000, 'fine']])
|
|
480
|
+
expect(model.graph!.nodes.every(n => n.id.startsWith('f'))).toBe(true)
|
|
481
|
+
// the bubble index is read beside every fine cut and no coarse one
|
|
482
|
+
expect(bubbleReads).toEqual(
|
|
483
|
+
cuts.filter(c => c.tier === 'fine').map(c => c.region),
|
|
484
|
+
)
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
test('with no coarse tier the margins narrow to the cap, and past it the last cut holds', async () => {
|
|
488
|
+
const { model, view, cuts } = await followingModel({
|
|
489
|
+
windowStart: 4_970_000,
|
|
490
|
+
tiered: false,
|
|
491
|
+
})
|
|
492
|
+
view.showWidth(2_000_000)
|
|
493
|
+
view.settle()
|
|
494
|
+
await flush()
|
|
495
|
+
expect(cuts).toHaveLength(2)
|
|
496
|
+
const region = model.loadedRegion!
|
|
497
|
+
expect(region.end - region.start).toBe(model.maxRegionBp)
|
|
498
|
+
|
|
499
|
+
view.showWidth(6_000_000)
|
|
500
|
+
view.settle()
|
|
501
|
+
await flush()
|
|
502
|
+
expect(cuts).toHaveLength(2)
|
|
503
|
+
expect(model.followNote).toMatch(/6 Mb is past the 5 Mb/)
|
|
504
|
+
expect(model.error).toBeUndefined()
|
|
505
|
+
expect(model.hasGraph).toBe(true)
|
|
506
|
+
|
|
507
|
+
view.showWidth(2_000_000)
|
|
508
|
+
view.settle()
|
|
509
|
+
await flush()
|
|
510
|
+
expect(cuts).toHaveLength(2)
|
|
511
|
+
expect(model.followNote).toBeUndefined()
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('in-flight re-cuts land latest-window-first', async () => {
|
|
515
|
+
const { model, view } = await followingModel()
|
|
516
|
+
const pending: { resolve: (gfa: string) => void; region: SubgraphRegion }[] =
|
|
517
|
+
[]
|
|
518
|
+
mockRpcCall.mockImplementation(
|
|
519
|
+
(_sid: unknown, _method: string, args: { region: SubgraphRegion }) =>
|
|
520
|
+
new Promise<string>(resolve => {
|
|
521
|
+
pending.push({ resolve, region: args.region })
|
|
522
|
+
}),
|
|
523
|
+
)
|
|
524
|
+
view.panBy(100_000)
|
|
525
|
+
view.settle()
|
|
526
|
+
view.panBy(100_000)
|
|
527
|
+
view.settle()
|
|
528
|
+
expect(pending).toHaveLength(2)
|
|
529
|
+
pending[1]!.resolve(syntheticGraph('fine', pending[1]!.region))
|
|
530
|
+
await flush()
|
|
531
|
+
pending[0]!.resolve(syntheticGraph('fine', pending[0]!.region))
|
|
532
|
+
await flush()
|
|
533
|
+
expect(model.loadedRegion).toEqual(pending[1]!.region)
|
|
534
|
+
const starts = model
|
|
535
|
+
.graph!.nodes.filter(n => n.stable?.rank === 0)
|
|
536
|
+
.map(n => n.stable!.start)
|
|
537
|
+
expect(Math.min(...starts)).toBeLessThanOrEqual(pending[1]!.region.start)
|
|
538
|
+
expect(Math.min(...starts)).toBeGreaterThan(pending[0]!.region.start)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
test('a gesture on the followed graph moves the linear view', async () => {
|
|
542
|
+
const { model, view } = await followingModel()
|
|
543
|
+
const start = view.windowStartBp
|
|
544
|
+
const y = model.translateY
|
|
545
|
+
|
|
546
|
+
model.setTransform(model.scale, model.translateX + 100, y + 30)
|
|
547
|
+
expect(view.windowStartBp).toBeCloseTo(start - 100 * view.bpPerPx, 6)
|
|
548
|
+
expect(model.translateY).toBe(y + 30)
|
|
549
|
+
expect(model.viewportOwner).toBe('follow')
|
|
550
|
+
|
|
551
|
+
const bpPerPx = view.bpPerPx
|
|
552
|
+
model.zoom(2, 250, 50)
|
|
553
|
+
expect(view.bpPerPx).toBeCloseTo(bpPerPx / 2, 9)
|
|
554
|
+
const probe = view.windowStartBp + 10_000
|
|
555
|
+
expect(graphX(model, probe)).toBeCloseTo(lgvX(view, probe), 6)
|
|
556
|
+
|
|
557
|
+
// the fit button places the rows and leaves x to the linear view
|
|
558
|
+
model.zoomToFit()
|
|
559
|
+
expect(graphX(model, probe)).toBeCloseTo(lgvX(view, probe), 6)
|
|
560
|
+
model.setTransform(model.scale, model.translateX, y + 30)
|
|
561
|
+
|
|
562
|
+
// the rows the reader scrolled to survive a re-cut
|
|
563
|
+
view.panBy(200_000)
|
|
564
|
+
view.settle()
|
|
565
|
+
await flush()
|
|
566
|
+
expect(model.followRecuts).toBe(1)
|
|
567
|
+
expect(model.translateY).toBe(y + 30)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('nothing runs on the force layout, and the toolbar is told why', async () => {
|
|
571
|
+
const { model, view, cuts } = await followingModel({ layoutMode: 'force' })
|
|
572
|
+
|
|
573
|
+
expect(model.followState).toEqual({
|
|
574
|
+
active: false,
|
|
575
|
+
reason: 'Not following: x is not reference bp (Force-directed layout)',
|
|
576
|
+
})
|
|
577
|
+
expect(model.viewportOwner).toBe('fit')
|
|
578
|
+
const fitted = [model.scale, model.translateX, model.translateY]
|
|
579
|
+
for (let step = 0; step < 20; step++) {
|
|
580
|
+
view.panBy(10_000)
|
|
581
|
+
view.settle()
|
|
582
|
+
await flush()
|
|
583
|
+
}
|
|
584
|
+
expect(cuts).toHaveLength(1)
|
|
585
|
+
expect(model.followRecuts).toBe(0)
|
|
586
|
+
expect([model.scale, model.translateX, model.translateY]).toEqual(fitted)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
test('switching an anchored follow to force hands the viewport back to the fit', async () => {
|
|
590
|
+
const { model } = await followingModel()
|
|
591
|
+
expect(model.viewportOwner).toBe('follow')
|
|
592
|
+
model.setLayoutMode('force')
|
|
593
|
+
await model.recomputeLayout()
|
|
594
|
+
expect(model.followState.active).toBe(false)
|
|
595
|
+
expect(model.viewportOwner).toBe('fit')
|
|
596
|
+
const handedBack = [model.scale, model.translateX, model.translateY]
|
|
597
|
+
model.zoomToFit()
|
|
598
|
+
expect([model.scale, model.translateX, model.translateY]).toEqual(handedBack)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
test('a GBZ cut is too slow to follow, and says so', async () => {
|
|
602
|
+
mockSession.tracks = [graphTrack({ type: 'GbzBaseSyntenyAdapter' })]
|
|
603
|
+
const { model, view, cuts } = await followingModel()
|
|
604
|
+
expect(model.followState).toEqual({
|
|
605
|
+
active: false,
|
|
606
|
+
reason: 'Not following: a GBZ cut takes seconds, too slow to redo per pan',
|
|
607
|
+
})
|
|
608
|
+
view.panBy(200_000)
|
|
609
|
+
view.settle()
|
|
610
|
+
await flush()
|
|
611
|
+
expect(cuts).toHaveLength(1)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
test('turning the follow off leaves the graph where the follow put it', async () => {
|
|
615
|
+
const { model, view } = await followingModel()
|
|
616
|
+
const transform = [model.scale, model.translateX, model.translateY]
|
|
617
|
+
model.setFollowLinearView(false)
|
|
618
|
+
expect(model.viewportOwner).toBe('user')
|
|
619
|
+
view.panBy(200_000)
|
|
620
|
+
view.settle()
|
|
621
|
+
await flush()
|
|
622
|
+
expect([model.scale, model.translateX, model.translateY]).toEqual(transform)
|
|
623
|
+
expect(model.followRecuts).toBe(0)
|
|
624
|
+
})
|