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
|
@@ -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
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
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
|
|
1200
|
-
const
|
|
1201
|
-
|
|
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(
|
|
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
|
-
|
|
1501
|
-
self.
|
|
1502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
1988
|
-
? `Region too large (${formatSpanBp(regionSize)}) — zoom in to view graph (max ${formatSpanBp(self.
|
|
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
|
|
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: `
|
|
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.
|
|
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.
|
|
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)
|
|
2351
|
-
//
|
|
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(() =>
|
|
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.
|
|
2892
|
+
self.viewportOwner = 'user'
|
|
2587
2893
|
}
|
|
2588
2894
|
// loadGFAFromLocation leaves `gfaLocation` intact, so the source
|
|
2589
2895
|
// round-trips through a session snapshot.
|