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
@@ -24,6 +24,13 @@ import {
24
24
  import { bubbleSubgraph } from './bubbles/popBubble'
25
25
  import { COLOR_SCHEME_VALUES } from './colorSchemes'
26
26
  import { deletionEdges } from './deletionEdges'
27
+ import {
28
+ cutHolds,
29
+ followCut,
30
+ followFrame,
31
+ followWindow,
32
+ isFollowable,
33
+ } from './follow'
27
34
  import {
28
35
  GENE_ADAPTER_TYPES,
29
36
  geneModelsFrom,
@@ -44,6 +51,7 @@ import { ROW_HEIGHT_PX } from './layout/rowSpacing'
44
51
  import { walkRowsExtent } from './layout/walkRowLayout'
45
52
  import { walkRows } from './layout/walkRows'
46
53
  import {
54
+ FORCE_LAYOUT_LABEL,
47
55
  LAYOUT_MODE_VALUES,
48
56
  layoutModeByValue,
49
57
  modeUsesLayoutEngine,
@@ -96,12 +104,14 @@ import {
96
104
 
97
105
  import type { BubbleSpread } from './bubbleSpreads'
98
106
  import type { ColorScheme, ResolvedColorScheme } from './colorSchemes'
107
+ import type { FollowedView } from './follow'
99
108
  import type { GeneModel } from './genes/geneFeatures'
100
109
  import type { LayoutScaling } from './layout/drawnScale'
101
110
  import type { LayoutModeValue } from './layoutModes'
102
111
  import type { NodeWidth } from './nodeWidths'
103
112
  import type { Renderer } from './renderer/types'
104
113
  import type { Graph, GraphNode, LayoutResult } from './types'
114
+ import type { SubgraphCutOptions, SubgraphTier } from '../GetSubgraph'
105
115
  import type { NodeInk } from './util/hitDetection'
106
116
  import type { MinigraphBubble } from '../MinigraphBubbleAdapter/bubbleLine'
107
117
  import type { RepeatArray } from './repeats/repeatFeatures'
@@ -218,6 +228,8 @@ function axisScaleOf(scale: number, pixelRows: boolean): AxisScale {
218
228
  return { scaleX: scale, scaleY: pixelRows ? 1 : scale, pixelRows }
219
229
  }
220
230
 
231
+ type ViewportOwner = 'fit' | 'user' | 'follow'
232
+
221
233
  interface Bounds {
222
234
  minX: number
223
235
  minY: number
@@ -258,6 +270,24 @@ function padded(v: Bounds, panes: number): Bounds {
258
270
  }
259
271
  }
260
272
 
273
+ // Centre the leftover, but only when there IS leftover. A row layout fits on x
274
+ // alone, so its drawing is routinely taller than the pane — that is the case
275
+ // rowSpacing.ts means by "reached by panning" — and centring an overflow splits
276
+ // the loss across both ends: at 41 rows the top row landed 100 px above the
277
+ // pane. The top row is the reference backbone, i.e. the axis the whole layout
278
+ // exists to put under the linear view, so it is the one row that must not be
279
+ // the first thing to go. Pinned at the padding it opens where a fitting drawing
280
+ // opens, and the rows past the ceiling are below, which is the direction a
281
+ // reader already scrolls a track in.
282
+ function fittedTranslateY(
283
+ bounds: { minY: number; h: number },
284
+ usableHeight: number,
285
+ yScale: number,
286
+ ) {
287
+ const leftoverY = usableHeight - bounds.h * yScale
288
+ return FIT_PADDING - bounds.minY * yScale + Math.max(0, leftoverY) / 2
289
+ }
290
+
261
291
  function contains(outer: Bounds, inner: Bounds) {
262
292
  return (
263
293
  inner.minX >= outer.minX &&
@@ -514,6 +544,13 @@ export default function stateModelFactory() {
514
544
  // draw its reference span there and vice versa. Written by the launch
515
545
  // menu; see hoverSync/.
516
546
  connectedViewId: types.maybe(types.string),
547
+ // x tracks that linear view's window, and the cut is re-made when the
548
+ // window leaves it. agent-docs/FOLLOW_THE_LINEAR_VIEW.md
549
+ followLinearView: types.optional(types.boolean, false),
550
+ // Whether the cut came from the source track's `coarse` pair
551
+ // (RgfaTabixAdapter), so a restored session re-makes the cut it saved.
552
+ // No bp cap applies to that pair; maxGraphNodes counts what came back.
553
+ coarseCut: types.optional(types.boolean, false),
517
554
  }),
518
555
  )
519
556
  .volatile(() => ({
@@ -564,13 +601,19 @@ export default function stateModelFactory() {
564
601
  // draggingNode instead of in a component ref+state pair, so the two
565
602
  // mutually exclusive drag modes are one piece of state read from one place.
566
603
  isPanning: false,
567
- // Set once the user pans/zooms (or a restored session carries a
568
- // transform). While false the view keeps auto-fitting as the layout and
569
- // canvas dimensions settle; a manual move opts out so we never fight the
570
- // user. Distinct from `isDefaultViewport`, which zoomToFit itself
571
- // invalidates on its first run — that made the fit fire once against
572
- // not-yet-measured dimensions and then never re-fit.
573
- userMovedViewport: false,
604
+ // Who places the view. 'fit' refits it to every new layout, 'user' leaves
605
+ // it where a gesture or a restored session put it, and 'follow' takes x
606
+ // from the followed linear view. Not derived from `isDefaultViewport`,
607
+ // which zoomToFit itself invalidates on its first run — that made the
608
+ // fit fire once against not-yet-measured dimensions and then never
609
+ // re-fit.
610
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
611
+ viewportOwner: 'fit' as ViewportOwner,
612
+ // the pane height a follow holds, so a re-cut with more rows does not
613
+ // move the pane while the linear view above it is being dragged
614
+ followPaneHeight: undefined as number | undefined,
615
+ followNote: undefined as string | undefined,
616
+ followRecuts: 0,
574
617
  viewportDirtyTimer: undefined as
575
618
  ReturnType<typeof setTimeout> | undefined,
576
619
  // 0 is never a live rAF handle, so it doubles as "nothing pending"
@@ -719,7 +762,9 @@ export default function stateModelFactory() {
719
762
  // The span the reference-position ramp runs over: what the snapshot
720
763
  // stated, else the region the graph was cut from. Undefined only for a
721
764
  // file-loaded graph that states neither, where the ramp falls back to the
722
- // drawn extent (computeReferenceRamp).
765
+ // drawn extent (computeReferenceRamp). A follow's re-cut moves
766
+ // loadedRegion, so the ramp re-spans every new cut, and the lane above
767
+ // with it.
723
768
  get rampDomain() {
724
769
  return self.colorDomain ?? self.loadedRegion
725
770
  },
@@ -795,6 +840,16 @@ export default function stateModelFactory() {
795
840
  },
796
841
  }))
797
842
  .views(self => ({
843
+ // As written: a session track is a plain object until something
844
+ // hydrates it, so a slot left at its default reads undefined here.
845
+ get sourceAdapter() {
846
+ const track = getSession(self).tracks.find(
847
+ t => t.trackId === self.loadedTrackId,
848
+ )
849
+ return track
850
+ ? (readConfObject(track, 'adapter') as Record<string, unknown>)
851
+ : undefined
852
+ },
798
853
  get assemblyTrackChoices() {
799
854
  const region = self.loadedRegion
800
855
  if (!region) {
@@ -1068,6 +1123,12 @@ export default function stateModelFactory() {
1068
1123
  // derivation. It reads neither `scale` nor the height it is replacing, so
1069
1124
  // zoomToFit consumes this without feeding back into it.
1070
1125
  get canvasHeight() {
1126
+ if (
1127
+ self.viewportOwner === 'follow' &&
1128
+ self.followPaneHeight !== undefined
1129
+ ) {
1130
+ return self.followPaneHeight
1131
+ }
1071
1132
  const bounds = this.layoutBounds
1072
1133
  const usableWidth = self.width - FIT_PADDING * 2
1073
1134
  // `paneHeight` replaces the built-in ceiling rather than adding a
@@ -1196,17 +1257,11 @@ export default function stateModelFactory() {
1196
1257
  // go supply it, and this is a probe run for hundreds of haplotypes per
1197
1258
  // graph.
1198
1259
  get assemblyResolver() {
1199
- const { assemblyManager, tracks } = getSession(self)
1200
- const track = tracks.find(t => t.trackId === self.loadedTrackId)
1201
- const adapter = track
1202
- ? (readConfObject(track, 'adapter') as {
1203
- assemblyNameToPanSN?: Record<string, string>
1204
- })
1205
- : undefined
1260
+ const { assemblyManager } = getSession(self)
1261
+ const panSN = self.sourceAdapter?.assemblyNameToPanSN as
1262
+ Record<string, string> | undefined
1206
1263
  const byPrefix = new Map(
1207
- Object.entries(adapter?.assemblyNameToPanSN ?? {}).map(
1208
- ([asm, prefix]) => [prefix, asm],
1209
- ),
1264
+ Object.entries(panSN ?? {}).map(([asm, prefix]) => [prefix, asm]),
1210
1265
  )
1211
1266
  const loaded = (name: string | undefined) =>
1212
1267
  name !== undefined && assemblyManager.has(name)
@@ -1290,6 +1345,103 @@ export default function stateModelFactory() {
1290
1345
  }
1291
1346
  },
1292
1347
  }))
1348
+ .views(self => ({
1349
+ // The linear view's zoom past which a follow cuts the source track's
1350
+ // coarse pair, or undefined for a track with none.
1351
+ get coarseAboveBpPerPx() {
1352
+ const coarse = self.sourceAdapter?.coarse as
1353
+ { aboveBpPerPx?: unknown } | undefined
1354
+ const above = coarse?.aboveBpPerPx
1355
+ return typeof above === 'number' ? above : undefined
1356
+ },
1357
+ }))
1358
+ .views(self => ({
1359
+ get cutTier(): SubgraphTier {
1360
+ return self.coarseCut && self.coarseAboveBpPerPx !== undefined
1361
+ ? 'coarse'
1362
+ : 'fine'
1363
+ },
1364
+ }))
1365
+ .views(self => ({
1366
+ get regionCapBp() {
1367
+ return self.cutTier === 'coarse' ? Infinity : self.maxRegionBp
1368
+ },
1369
+ }))
1370
+ .views(self => ({
1371
+ // Whether the follow is running, and when it is asked for and cannot,
1372
+ // why not: a control that silently does nothing is worse than one that
1373
+ // says it cannot (EngineOnly in GraphSettingsDialog).
1374
+ get followState():
1375
+ | { active: true; view: FollowedView }
1376
+ | { active: false; reason: string | undefined } {
1377
+ const region = self.loadedRegion
1378
+ const layout = self.layoutResult
1379
+ if (!self.followLinearView) {
1380
+ return { active: false, reason: undefined }
1381
+ }
1382
+ if (!region || !self.loadedTrackId) {
1383
+ return {
1384
+ active: false,
1385
+ reason: 'Only a graph cut from a track can follow a linear view',
1386
+ }
1387
+ }
1388
+ if (self.sourceAdapter?.type === 'GbzBaseSyntenyAdapter') {
1389
+ return {
1390
+ active: false,
1391
+ reason:
1392
+ 'Not following: a GBZ cut takes seconds, too slow to redo per pan',
1393
+ }
1394
+ }
1395
+ const view = linearViewTarget({
1396
+ views: [...getSession(self).views],
1397
+ connectedViewId: self.connectedViewId,
1398
+ assemblyName: region.assemblyName,
1399
+ })
1400
+ if (!isFollowable(view)) {
1401
+ return {
1402
+ active: false,
1403
+ reason: `No linear view on ${region.assemblyName} to follow`,
1404
+ }
1405
+ }
1406
+ if (!layout || !view.initialized) {
1407
+ return { active: false, reason: undefined }
1408
+ }
1409
+ if (!layout.referenceAxis) {
1410
+ const label = self.usesLayoutEngine
1411
+ ? FORCE_LAYOUT_LABEL
1412
+ : layoutModeByValue(self.layoutMode).label
1413
+ return {
1414
+ active: false,
1415
+ reason: `Not following: x is not reference bp (${label})`,
1416
+ }
1417
+ }
1418
+ if (self.popStack.length > 0) {
1419
+ return {
1420
+ active: false,
1421
+ reason: 'Not following while a bubble is open',
1422
+ }
1423
+ }
1424
+ if (
1425
+ view.dynamicBlocks.contentBlocks.some(
1426
+ b => b.refName === region.refName && b.reversed,
1427
+ )
1428
+ ) {
1429
+ return {
1430
+ active: false,
1431
+ reason: 'Not following: the linear view shows this region reversed',
1432
+ }
1433
+ }
1434
+ return { active: true, view }
1435
+ },
1436
+ }))
1437
+ .views(self => ({
1438
+ get followTransform() {
1439
+ const follow = self.followState
1440
+ return follow.active && self.loadedRegion
1441
+ ? followFrame(follow.view, self.loadedRegion)
1442
+ : undefined
1443
+ },
1444
+ }))
1293
1445
  .actions(self => ({
1294
1446
  setError(error: unknown) {
1295
1447
  self.error = error
@@ -1496,14 +1648,26 @@ export default function stateModelFactory() {
1496
1648
  }
1497
1649
  },
1498
1650
 
1651
+ // A gesture on a followed graph moves the linear view, and the frame
1652
+ // clock brings x back here; y stays the pane's own.
1499
1653
  setTransform(s: number, tx: number, ty: number) {
1500
- self.userMovedViewport = true
1501
- self.scale = clampZoom(s)
1502
- self.translateX = tx
1654
+ const follow = self.followState
1655
+ if (self.viewportOwner === 'follow' && follow.active) {
1656
+ follow.view.horizontalScroll(self.translateX - tx)
1657
+ } else {
1658
+ self.viewportOwner = 'user'
1659
+ self.scale = clampZoom(s)
1660
+ self.translateX = tx
1661
+ }
1503
1662
  self.translateY = ty
1504
1663
  },
1505
1664
  zoom(factor: number, centerX: number, centerY: number) {
1506
- self.userMovedViewport = true
1665
+ const follow = self.followState
1666
+ if (self.viewportOwner === 'follow' && follow.active) {
1667
+ follow.view.zoomTo(follow.view.bpPerPx / factor, centerX)
1668
+ return
1669
+ }
1670
+ self.viewportOwner = 'user'
1507
1671
  const newScale = clampZoom(self.scale * factor)
1508
1672
  const ratio = newScale / self.scale
1509
1673
  self.scale = newScale
@@ -1533,6 +1697,17 @@ export default function stateModelFactory() {
1533
1697
  const bounds = self.layoutBounds
1534
1698
  const usableWidth = self.width - FIT_PADDING * 2
1535
1699
  const usableHeight = self.canvasHeight - FIT_PADDING * 2
1700
+ // A follow owns x, so a fit while following places the rows only.
1701
+ if (self.viewportOwner === 'follow') {
1702
+ if (bounds && usableHeight > 0) {
1703
+ self.translateY = fittedTranslateY(
1704
+ bounds,
1705
+ usableHeight,
1706
+ self.scaleY,
1707
+ )
1708
+ }
1709
+ return
1710
+ }
1536
1711
  // Nothing to fit into before the canvas is measured. The autorun re-runs
1537
1712
  // once width lands, so skipping here beats computing a negative scale
1538
1713
  // and persisting that transform into the session snapshot.
@@ -1565,20 +1740,11 @@ export default function stateModelFactory() {
1565
1740
  bounds.minX * newScale +
1566
1741
  (usableWidth - bounds.w * newScale) / 2
1567
1742
  // scaleY, which a row layout pins at 1
1568
- const yScale = self.pixelRows ? 1 : newScale
1569
- // Centre the leftover, but only when there IS leftover. A row layout
1570
- // fits on x alone, so its drawing is routinely taller than the pane —
1571
- // that is the case rowSpacing.ts means by "reached by panning" — and
1572
- // centring an overflow splits the loss across both ends: at 41 rows
1573
- // the top row landed 100 px above the pane. The top row is the
1574
- // reference backbone, i.e. the axis the whole layout exists to put
1575
- // under the linear view, so it is the one row that must not be the
1576
- // first thing to go. Pinned at the padding it opens where a fitting
1577
- // drawing opens, and the rows past the ceiling are below, which is the
1578
- // direction a reader already scrolls a track in.
1579
- const leftoverY = usableHeight - bounds.h * yScale
1580
- self.translateY =
1581
- FIT_PADDING - bounds.minY * yScale + Math.max(0, leftoverY) / 2
1743
+ self.translateY = fittedTranslateY(
1744
+ bounds,
1745
+ usableHeight,
1746
+ self.pixelRows ? 1 : newScale,
1747
+ )
1582
1748
  }
1583
1749
  },
1584
1750
  clearPerfMetrics() {
@@ -1589,6 +1755,39 @@ export default function stateModelFactory() {
1589
1755
  self.builtViewport = undefined
1590
1756
  },
1591
1757
  }))
1758
+ .actions(self => ({
1759
+ setFollowLinearView(follow: boolean) {
1760
+ self.followLinearView = follow
1761
+ },
1762
+ // The frame clock. Unclamped, since clampZoom's floor is there to keep a
1763
+ // scale finite and 1 / bpPerPx already is; clamped, a whole-chromosome
1764
+ // window would stop lining up.
1765
+ followTo(scale: number, translateX: number) {
1766
+ const engaging = self.viewportOwner !== 'follow'
1767
+ if (engaging) {
1768
+ self.followPaneHeight = self.canvasHeight
1769
+ self.viewportOwner = 'follow'
1770
+ }
1771
+ self.scale = scale
1772
+ self.translateX = translateX
1773
+ if (engaging) {
1774
+ self.zoomToFit()
1775
+ }
1776
+ },
1777
+ // A drawing still on the reference stays where the follow left it; one
1778
+ // whose x no longer means bp is refit.
1779
+ stopFollowing() {
1780
+ if (self.viewportOwner === 'follow') {
1781
+ self.followPaneHeight = undefined
1782
+ if (self.layoutResult?.referenceAxis) {
1783
+ self.viewportOwner = 'user'
1784
+ } else {
1785
+ self.viewportOwner = 'fit'
1786
+ self.zoomToFit()
1787
+ }
1788
+ }
1789
+ },
1790
+ }))
1592
1791
  .actions(self => {
1593
1792
  // Pan/zoom can wait: the geometry on screen is still correct while the
1594
1793
  // gesture runs (the viewport bounds carry 20% overscan), so rebuilding is
@@ -1709,6 +1908,9 @@ export default function stateModelFactory() {
1709
1908
  const local = layoutModeByValue(self.layoutMode).run(
1710
1909
  graph,
1711
1910
  self.loadedRegion,
1911
+ self.viewportOwner === 'follow'
1912
+ ? self.layoutResult?.sampleRows
1913
+ : undefined,
1712
1914
  )
1713
1915
  if (local) {
1714
1916
  return { result: local, duration: performance.now() - start }
@@ -1801,7 +2003,13 @@ export default function stateModelFactory() {
1801
2003
  return live
1802
2004
  }
1803
2005
 
1804
- function* parseAndLayout(text: string, name: string) {
2006
+ // `keepSelection` for a re-cut of the same source: node ids survive one
2007
+ // where edge indexes do not, so the selection is found again by id.
2008
+ function* parseAndLayout(
2009
+ text: string,
2010
+ name: string,
2011
+ keepSelection = false,
2012
+ ) {
1805
2013
  self.setStatusMessage('Parsing GFA')
1806
2014
  const gfaGraph = parseGFA(text)
1807
2015
  // A general GFA states its coordinates only in its P/W lines, so the
@@ -1825,6 +2033,7 @@ export default function stateModelFactory() {
1825
2033
  `Graph too large to draw: ${graph.nodes.length.toLocaleString()} nodes (limit ${self.maxGraphNodes.toLocaleString()}). Zoom in to a smaller region, or raise maxGraphNodes on this view.`,
1826
2034
  )
1827
2035
  }
2036
+ const selected = keepSelection ? self.selectedNode : null
1828
2037
  self.graph = graph
1829
2038
  self.indexBubbles = undefined
1830
2039
  self.geneFeatures = undefined
@@ -1835,6 +2044,9 @@ export default function stateModelFactory() {
1835
2044
  // them over points the tooltip and the highlight at whatever now happens
1836
2045
  // to sit at that index.
1837
2046
  self.clearInteractionState()
2047
+ if (selected !== null && self.nodeById?.has(selected)) {
2048
+ self.selectedNode = selected
2049
+ }
1838
2050
  self.setStatusMessage('Computing layout')
1839
2051
  yield* layoutInto(graph)
1840
2052
  }
@@ -1865,7 +2077,8 @@ export default function stateModelFactory() {
1865
2077
  // `<prefix>.bubbles.bed.gz`, read through the bubble adapter over the same
1866
2078
  // window. A graph whose source has no such file, the ordinary case for a
1867
2079
  // graph of one's own, keeps its derived bubbles, so a failure here is not
1868
- // the graph's problem.
2080
+ // the graph's problem. A coarse cut reads none: its nodes are the
2081
+ // bubbles, and the index over its window can run to a chromosome's rows.
1869
2082
  function* loadBubbles(
1870
2083
  adapterConfig: Record<string, unknown>,
1871
2084
  region: SubgraphRegion,
@@ -1874,7 +2087,11 @@ export default function stateModelFactory() {
1874
2087
  // The track config arrives as written, so the prefix is either the
1875
2088
  // `uri` shorthand or the segments location it expands to.
1876
2089
  const prefix = bubblePrefix(adapterConfig)
1877
- if (adapterConfig.type !== 'RgfaTabixAdapter' || prefix === undefined) {
2090
+ if (
2091
+ adapterConfig.type !== 'RgfaTabixAdapter' ||
2092
+ prefix === undefined ||
2093
+ self.cutTier === 'coarse'
2094
+ ) {
1878
2095
  return
1879
2096
  }
1880
2097
  try {
@@ -1967,10 +2184,7 @@ export default function stateModelFactory() {
1967
2184
  function* doSubgraphLoad(
1968
2185
  adapterConfig: Record<string, unknown>,
1969
2186
  region: SubgraphRegion,
1970
- opts: {
1971
- hops?: number
1972
- haplotypes?: string[]
1973
- } = {},
2187
+ opts: SubgraphCutOptions = {},
1974
2188
  ) {
1975
2189
  const { isLive, signal } = beginLoad()
1976
2190
  const track = loadedTrack()
@@ -1984,8 +2198,8 @@ export default function stateModelFactory() {
1984
2198
  graphReferenceAssembly(track),
1985
2199
  region.assemblyName,
1986
2200
  )) ??
1987
- (regionSize > self.maxRegionBp
1988
- ? `Region too large (${formatSpanBp(regionSize)}) — zoom in to view graph (max ${formatSpanBp(self.maxRegionBp)})`
2201
+ (regionSize > self.regionCapBp
2202
+ ? `Region too large (${formatSpanBp(regionSize)}) — zoom in to view graph (max ${formatSpanBp(self.regionCapBp)})`
1989
2203
  : undefined)
1990
2204
  if (refusal !== undefined) {
1991
2205
  self.graph = undefined
@@ -2005,7 +2219,7 @@ export default function stateModelFactory() {
2005
2219
  const gfaText = (yield rpcManager.call(sessionId, 'GetSubgraph', {
2006
2220
  adapterConfig,
2007
2221
  region,
2008
- opts: { hops: opts.hops, haplotypes: opts.haplotypes },
2222
+ opts,
2009
2223
  signal,
2010
2224
  })) as string
2011
2225
  if (!isLive()) {
@@ -2018,7 +2232,7 @@ export default function stateModelFactory() {
2018
2232
  )
2019
2233
  }
2020
2234
  const label = locLabel(region)
2021
- yield* parseAndLayout(gfaText, label)
2235
+ yield* parseAndLayout(gfaText, label, true)
2022
2236
  if (!isLive()) {
2023
2237
  return
2024
2238
  }
@@ -2049,7 +2263,7 @@ export default function stateModelFactory() {
2049
2263
  // track the session lacks is reported, because otherwise the view sits on
2050
2264
  // an empty import form with nothing saying why.
2051
2265
  //
2052
- // Nothing here saves and restores the transform: `userMovedViewport` is
2266
+ // Nothing here saves and restores the transform: `viewportOwner` is
2053
2267
  // what protects a restored session's pan/zoom, and it gates the fit
2054
2268
  // autorun — the only thing in this flow that would otherwise move the
2055
2269
  // view. A save/restore pair around the load wrote back the values it had
@@ -2062,9 +2276,13 @@ export default function stateModelFactory() {
2062
2276
  `The track this graph was cut from, "${self.loadedTrackId}", is not in this session`,
2063
2277
  )
2064
2278
  } else if (track && region) {
2279
+ // A hop past a coarse cut reaches nothing new: every bubble node's
2280
+ // two links are indexed under the backbone either side of it.
2281
+ const coarse = self.cutTier === 'coarse'
2065
2282
  yield* doSubgraphLoad(readConfObject(track, 'adapter'), region, {
2066
- hops: self.subgraphContext,
2283
+ hops: coarse ? 0 : self.subgraphContext,
2067
2284
  haplotypes: self.subgraphHaplotypes,
2285
+ tier: coarse ? 'coarse' : undefined,
2068
2286
  })
2069
2287
  }
2070
2288
  }
@@ -2084,6 +2302,8 @@ export default function stateModelFactory() {
2084
2302
  const { isLive, signal } = beginLoad()
2085
2303
  self.loadedTrackId = ''
2086
2304
  self.loadedRegion = region
2305
+ self.coarseCut = false
2306
+ self.followLinearView = false
2087
2307
  self.isLoading = true
2088
2308
  self.error = undefined
2089
2309
  try {
@@ -2122,6 +2342,8 @@ export default function stateModelFactory() {
2122
2342
  self.layoutResult = undefined
2123
2343
  self.loadedTrackId = ''
2124
2344
  self.loadedRegion = undefined
2345
+ self.coarseCut = false
2346
+ self.followNote = undefined
2125
2347
  self.gfaLocation = undefined
2126
2348
  self.indexBubbles = undefined
2127
2349
  self.geneFeatures = undefined
@@ -2173,6 +2395,7 @@ export default function stateModelFactory() {
2173
2395
  ) {
2174
2396
  self.loadedTrackId = opts.trackId ?? ''
2175
2397
  self.loadedRegion = opts.trackId ? region : undefined
2398
+ self.coarseCut = false
2176
2399
  yield* doSubgraphLoad(adapterConfig, region, {
2177
2400
  hops: self.subgraphContext,
2178
2401
  haplotypes: self.subgraphHaplotypes,
@@ -2254,7 +2477,7 @@ export default function stateModelFactory() {
2254
2477
  self.layoutMode = 'force'
2255
2478
  }
2256
2479
  self.clearInteractionState()
2257
- self.userMovedViewport = false
2480
+ self.viewportOwner = 'fit'
2258
2481
  self.isLoading = true
2259
2482
  try {
2260
2483
  if (yield* layoutInto(self.graph)) {
@@ -2277,7 +2500,7 @@ export default function stateModelFactory() {
2277
2500
  self.layoutMode = from.layoutMode
2278
2501
  self.indexBubbles = from.indexBubbles
2279
2502
  self.clearInteractionState()
2280
- self.userMovedViewport = false
2503
+ self.viewportOwner = 'fit'
2281
2504
  self.isLoading = true
2282
2505
  try {
2283
2506
  if (yield* layoutInto(from.graph)) {
@@ -2312,6 +2535,38 @@ export default function stateModelFactory() {
2312
2535
  }),
2313
2536
  }
2314
2537
  })
2538
+ .actions(self => ({
2539
+ // The settle clock. A pan the cut still holds fetches nothing; one past
2540
+ // its edge re-cuts the window plus a window-width each side, on the tier
2541
+ // the zoom asks for. Overlapping re-cuts are ordered by doSubgraphLoad's
2542
+ // liveLoad, so the latest window wins.
2543
+ followSettle() {
2544
+ const follow = self.followState
2545
+ const seen = follow.active ? followWindow(follow.view) : undefined
2546
+ if (!seen) {
2547
+ return
2548
+ }
2549
+ const above = self.coarseAboveBpPerPx
2550
+ const tier =
2551
+ above !== undefined && seen.bpPerPx > above ? 'coarse' : 'fine'
2552
+ const cap = tier === 'coarse' ? Infinity : self.maxRegionBp
2553
+ const visible = seen.end - seen.start
2554
+ self.followNote =
2555
+ visible > cap
2556
+ ? `Holding the last cut: ${formatSpanBp(visible)} is past the ${formatSpanBp(cap)} a cut may span`
2557
+ : undefined
2558
+ if (
2559
+ self.followNote !== undefined ||
2560
+ (tier === self.cutTier && cutHolds(self.loadedRegion, seen))
2561
+ ) {
2562
+ return
2563
+ }
2564
+ self.coarseCut = tier === 'coarse'
2565
+ self.loadedRegion = followCut(seen, cap)
2566
+ self.followRecuts++
2567
+ void self.reloadSubgraph()
2568
+ },
2569
+ }))
2315
2570
  .actions(self => ({
2316
2571
  startRenderingBackend(backend: Renderer) {
2317
2572
  if (!self.autorunsInstalled) {
@@ -2347,20 +2602,71 @@ export default function stateModelFactory() {
2347
2602
  // Reads layoutResult plus (via zoomToFit) width/canvasHeight, so it
2348
2603
  // re-fires — and re-fits — as the layout arrives and the canvas is
2349
2604
  // measured, rather than firing once against not-yet-known dimensions.
2350
- // A manual pan/zoom (or a restored-session transform) sets
2351
- // userMovedViewport, opting out so we never override the user.
2605
+ // A manual pan/zoom (or a restored-session transform) makes the
2606
+ // viewport the user's, and a follow makes it the linear view's.
2352
2607
  addDisposer(
2353
2608
  self,
2354
2609
  autorun(() => {
2355
2610
  if (
2356
2611
  self.layoutResult &&
2357
- untracked(() => !self.userMovedViewport)
2612
+ untracked(() => self.viewportOwner === 'fit')
2358
2613
  ) {
2359
2614
  self.zoomToFit()
2360
2615
  }
2361
2616
  }),
2362
2617
  )
2363
2618
 
2619
+ // The follow's two clocks. The frame clock moves x with every frame
2620
+ // of the linear view and fetches nothing; the settle clock wakes on
2621
+ // its debounced blocks and re-cuts only when the window has left
2622
+ // the cut.
2623
+ addDisposer(
2624
+ self,
2625
+ reaction(
2626
+ () => self.followState.active,
2627
+ active => {
2628
+ if (!active) {
2629
+ self.stopFollowing()
2630
+ }
2631
+ },
2632
+ { name: 'GraphFollowActive' },
2633
+ ),
2634
+ )
2635
+ addDisposer(
2636
+ self,
2637
+ reaction(
2638
+ () => self.followTransform,
2639
+ frame => {
2640
+ if (frame) {
2641
+ self.followTo(frame.scale, frame.translateX)
2642
+ }
2643
+ },
2644
+ {
2645
+ equals: (a, b) =>
2646
+ a?.scale === b?.scale && a?.translateX === b?.translateX,
2647
+ fireImmediately: true,
2648
+ name: 'GraphFollowFrame',
2649
+ },
2650
+ ),
2651
+ )
2652
+ addDisposer(
2653
+ self,
2654
+ reaction(
2655
+ () => {
2656
+ const follow = self.followState
2657
+ return follow.active
2658
+ ? follow.view.coarseDynamicBlocks
2659
+ : undefined
2660
+ },
2661
+ blocks => {
2662
+ if (blocks) {
2663
+ self.followSettle()
2664
+ }
2665
+ },
2666
+ { fireImmediately: true, name: 'GraphFollowSettle' },
2667
+ ),
2668
+ )
2669
+
2364
2670
  // Autorun: mirror a connected linear view's hover onto the graph. An
2365
2671
  // LGV writes `{hoverPosition, hoverFeature}` to session.hovered on
2366
2672
  // every mousemove; neither field names the source view, so the guard
@@ -2583,7 +2889,7 @@ export default function stateModelFactory() {
2583
2889
  // A restored session that already carries a non-default transform is
2584
2890
  // the user's own view — mark it so the fit autorun leaves it alone.
2585
2891
  if (!self.isDefaultViewport) {
2586
- self.userMovedViewport = true
2892
+ self.viewportOwner = 'user'
2587
2893
  }
2588
2894
  // loadGFAFromLocation leaves `gfaLocation` intact, so the source
2589
2895
  // round-trips through a session snapshot.