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.
Files changed (58) hide show
  1. package/README.md +29 -2
  2. package/dist/chunks/GbzBaseSyntenyAdapter-MIOUYHOF.js +8 -0
  3. package/dist/chunks/GbzBaseSyntenyAdapter-MIOUYHOF.js.map +7 -0
  4. package/dist/chunks/GraphGenomeView-TM7WYDLH.js +14 -0
  5. package/dist/chunks/GraphGenomeView-TM7WYDLH.js.map +7 -0
  6. package/dist/chunks/MinigraphBubbleAdapter-A7SBF2XY.js +2 -0
  7. package/dist/chunks/MinigraphBubbleAdapter-A7SBF2XY.js.map +7 -0
  8. package/dist/chunks/RgfaTabixAdapter-7ZGKJJYW.js +3 -0
  9. package/dist/chunks/RgfaTabixAdapter-7ZGKJJYW.js.map +7 -0
  10. package/dist/chunks/{chunk-X3X74OGB.js → chunk-7T2HTRP3.js} +2 -2
  11. package/dist/chunks/{chunk-X3X74OGB.js.map → chunk-7T2HTRP3.js.map} +3 -3
  12. package/dist/chunks/{chunk-JCG7RLMB.js → chunk-HOM2Y6XS.js} +2 -2
  13. package/dist/chunks/chunk-HOM2Y6XS.js.map +7 -0
  14. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  15. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  16. package/package.json +21 -22
  17. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.test.ts +69 -0
  18. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.ts +13 -10
  19. package/src/GetSubgraph.ts +5 -1
  20. package/src/GraphGenomeView/components/FollowControl.test.tsx +69 -0
  21. package/src/GraphGenomeView/components/FollowControl.tsx +42 -0
  22. package/src/GraphGenomeView/components/GraphStats.tsx +1 -0
  23. package/src/GraphGenomeView/components/GraphToolbar.tsx +2 -0
  24. package/src/GraphGenomeView/follow.test.ts +624 -0
  25. package/src/GraphGenomeView/follow.ts +129 -0
  26. package/src/GraphGenomeView/layout/sampleRowLayout.test.ts +16 -0
  27. package/src/GraphGenomeView/layout/sampleRowLayout.ts +13 -4
  28. package/src/GraphGenomeView/layoutModes.ts +3 -1
  29. package/src/GraphGenomeView/model.ts +361 -55
  30. package/src/GraphGenomeView/types.ts +4 -0
  31. package/src/MinigraphBubbleAdapter/MinigraphBubbleAdapter.ts +1 -1
  32. package/src/RgfaTabixAdapter/RgfaTabixAdapter.test.ts +73 -0
  33. package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +48 -16
  34. package/src/RgfaTabixAdapter/configSchema.ts +70 -3
  35. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.links.bed.gz +0 -0
  36. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.links.bed.gz.tbi +0 -0
  37. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.segs.bed.gz +0 -0
  38. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.tier1000.segs.bed.gz.tbi +0 -0
  39. package/src/launchSubgraph/index.ts +12 -6
  40. package/src/launchSubgraph/launchSubgraph.test.ts +89 -3
  41. package/src/launchSubgraph/launchSubgraphView.ts +14 -6
  42. package/src/launchSubgraph/linearViewMenuItems.ts +3 -0
  43. package/src/launchSubgraph/subgraphMenuItems.ts +7 -0
  44. package/src/launchSubgraph/testEnv.ts +2 -0
  45. package/src/panSNTabix.ts +6 -5
  46. package/src/synteny/ComparativeAdapterBase.ts +13 -10
  47. package/src/synteny/clipFeatureToRegion.test.ts +41 -1
  48. package/src/synteny/clipFeatureToRegion.ts +22 -5
  49. package/src/version.ts +1 -1
  50. package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js +0 -8
  51. package/dist/chunks/GbzBaseSyntenyAdapter-6UTRY4CV.js.map +0 -7
  52. package/dist/chunks/GraphGenomeView-UD5WCV3Q.js +0 -14
  53. package/dist/chunks/GraphGenomeView-UD5WCV3Q.js.map +0 -7
  54. package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js +0 -2
  55. package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js.map +0 -7
  56. package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js +0 -3
  57. package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js.map +0 -7
  58. 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
+ })