jbrowse-plugin-graphgenomeviewer 4.0.11 → 4.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +22 -4
  2. package/dist/chunks/GbzBaseSyntenyAdapter-Y5NBXK7P.js +8 -0
  3. package/dist/chunks/{GbzBaseSyntenyAdapter-URQ4Z2JB.js.map → GbzBaseSyntenyAdapter-Y5NBXK7P.js.map} +4 -4
  4. package/dist/chunks/{GraphGenomeView-44GJL4RA.js → GraphGenomeView-TTQEJJQD.js} +2 -2
  5. package/dist/chunks/{GraphGenomeView-44GJL4RA.js.map → GraphGenomeView-TTQEJJQD.js.map} +1 -1
  6. package/dist/chunks/GraphTrackSettingsDialog-3A2PKZRI.js +4 -0
  7. package/dist/chunks/{GraphTrackSettingsDialog-LZITZ3VD.js.map → GraphTrackSettingsDialog-3A2PKZRI.js.map} +1 -1
  8. package/dist/chunks/LinearGraphDisplay-MJ5XZFE5.js +2 -0
  9. package/dist/chunks/{LinearGraphDisplay-BWBXVSEQ.js.map → LinearGraphDisplay-MJ5XZFE5.js.map} +1 -1
  10. package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js → MinigraphBubbleAdapter-APC7JYOM.js} +2 -2
  11. package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js → RgfaTabixAdapter-4E365PXO.js} +2 -2
  12. package/dist/chunks/{chunk-LGGAXAS6.js → chunk-5DP7IXD3.js} +2 -2
  13. package/dist/chunks/chunk-CJ56O2VG.js +2 -0
  14. package/dist/chunks/chunk-CJ56O2VG.js.map +7 -0
  15. package/dist/chunks/chunk-EFVAQ2KM.js +2 -0
  16. package/dist/chunks/chunk-EFVAQ2KM.js.map +7 -0
  17. package/dist/chunks/chunk-IFRDWMQ2.js +2 -0
  18. package/dist/chunks/chunk-IFRDWMQ2.js.map +7 -0
  19. package/dist/chunks/chunk-JAQ2RQAE.js +2 -0
  20. package/dist/chunks/{chunk-QRVREF7H.js.map → chunk-JAQ2RQAE.js.map} +4 -4
  21. package/dist/chunks/{chunk-WETM2CD7.js → chunk-LA7MQS33.js} +2 -2
  22. package/dist/chunks/chunk-M4YEAXSR.js +2 -0
  23. package/dist/chunks/chunk-M4YEAXSR.js.map +7 -0
  24. package/dist/chunks/chunk-XDOIZTY6.js +4 -0
  25. package/dist/chunks/chunk-XDOIZTY6.js.map +7 -0
  26. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +2 -2
  27. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  28. package/package.json +4 -4
  29. package/src/GbzBaseSyntenyAdapter/configSchema.ts +3 -4
  30. package/src/GraphGenomeView/components/GraphCanvas.tsx +4 -11
  31. package/src/GraphGenomeView/components/TubeMapOverlay.tsx +117 -4
  32. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +261 -44
  33. package/src/GraphGenomeView/components/referenceRampCss.test.ts +27 -0
  34. package/src/GraphGenomeView/components/referenceRampCss.ts +39 -0
  35. package/src/GraphGenomeView/genes/genes.test.ts +9 -2
  36. package/src/GraphGenomeView/host.test.ts +25 -2
  37. package/src/GraphGenomeView/host.ts +9 -7
  38. package/src/GraphGenomeView/model.test.ts +383 -4
  39. package/src/GraphGenomeView/model.ts +413 -88
  40. package/src/GraphGenomeView/repeats/repeatFeatures.test.ts +75 -0
  41. package/src/GraphGenomeView/repeats/repeatFeatures.ts +156 -8
  42. package/src/GraphGenomeView/repeats/walkCalls.test.ts +18 -0
  43. package/src/GraphGenomeView/repeats/walkCalls.ts +35 -18
  44. package/src/GraphGenomeView/subgraphLoad.test.ts +1 -0
  45. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +165 -6
  46. package/src/LinearGraphDisplay/model.ts +61 -1
  47. package/src/gaf/gafFile.test.ts +18 -0
  48. package/src/version.ts +1 -1
  49. package/dist/chunks/GbzBaseSyntenyAdapter-URQ4Z2JB.js +0 -8
  50. package/dist/chunks/GraphTrackSettingsDialog-LZITZ3VD.js +0 -4
  51. package/dist/chunks/LinearGraphDisplay-BWBXVSEQ.js +0 -2
  52. package/dist/chunks/chunk-ADEBM7AA.js +0 -2
  53. package/dist/chunks/chunk-ADEBM7AA.js.map +0 -7
  54. package/dist/chunks/chunk-MWSRAF5B.js +0 -4
  55. package/dist/chunks/chunk-MWSRAF5B.js.map +0 -7
  56. package/dist/chunks/chunk-QRVREF7H.js +0 -2
  57. package/dist/chunks/chunk-ZYQJBNS5.js +0 -2
  58. package/dist/chunks/chunk-ZYQJBNS5.js.map +0 -7
  59. /package/dist/chunks/{MinigraphBubbleAdapter-QTXGK7DW.js.map → MinigraphBubbleAdapter-APC7JYOM.js.map} +0 -0
  60. /package/dist/chunks/{RgfaTabixAdapter-O3ERR2GY.js.map → RgfaTabixAdapter-4E365PXO.js.map} +0 -0
  61. /package/dist/chunks/{chunk-LGGAXAS6.js.map → chunk-5DP7IXD3.js.map} +0 -0
  62. /package/dist/chunks/{chunk-WETM2CD7.js.map → chunk-LA7MQS33.js.map} +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  } from '@jbrowse/bandage-core/colorSchemes'
19
19
  import { deletionEdges } from '@jbrowse/bandage-core/deletionEdges'
20
20
  import { genePins } from '@jbrowse/bandage-core/genes/genePins'
21
+ import { rowLabelBox } from '@jbrowse/bandage-core/graphLabels'
21
22
  import { layoutLabels } from '@jbrowse/bandage-core/labelLayout'
22
23
  import { ROW_HEIGHT_PX } from '@jbrowse/bandage-core/layout/rowSpacing'
23
24
  import { walkRowsExtent } from '@jbrowse/bandage-core/layout/walkRowLayout'
@@ -29,7 +30,10 @@ import {
29
30
  modeUsesLayoutEngine,
30
31
  } from '@jbrowse/bandage-core/layoutModes'
31
32
  import { NODE_WIDTH_VALUES, nodeInk } from '@jbrowse/bandage-core/nodeWidths'
32
- import { anchorFromPaths } from '@jbrowse/bandage-core/pathAnchoring'
33
+ import {
34
+ anchorFromPaths,
35
+ chooseReferencePath,
36
+ } from '@jbrowse/bandage-core/pathAnchoring'
33
37
  import { pathColorsLegible, pathLegend } from '@jbrowse/bandage-core/pathColors'
34
38
  import {
35
39
  FIT_PADDING,
@@ -41,6 +45,12 @@ import {
41
45
  forceLayout,
42
46
  loadGraph,
43
47
  } from '@jbrowse/bandage-core/pipeline'
48
+ import {
49
+ assemblyWalk,
50
+ backboneAssembly,
51
+ featuresOnBackbone,
52
+ graphBackbone,
53
+ } from '@jbrowse/bandage-core/reference'
44
54
  import {
45
55
  buildNeighbors,
46
56
  nodeReferenceSpan,
@@ -49,11 +59,28 @@ import {
49
59
  buildGeometry,
50
60
  computeReferenceRamp,
51
61
  } from '@jbrowse/bandage-core/renderer/GeometryBuilder'
62
+ import {
63
+ referenceBoxes,
64
+ rulerBoxes,
65
+ rulerMarks,
66
+ } from '@jbrowse/bandage-core/tubeMap/axis'
67
+ import { coarsenTubeMap } from '@jbrowse/bandage-core/tubeMap/coarsen'
68
+ import {
69
+ connectorAt,
70
+ referenceNodes,
71
+ tubeMapConnectors,
72
+ } from '@jbrowse/bandage-core/tubeMap/connectors'
73
+ import { deviationMarks } from '@jbrowse/bandage-core/tubeMap/deviations'
52
74
  import { tubeMapPicture } from '@jbrowse/bandage-core/tubeMap/draw'
53
75
  import {
54
76
  tubeMapFrame,
55
77
  tubeMapNodeAt,
56
78
  } from '@jbrowse/bandage-core/tubeMap/frame'
79
+ import {
80
+ GENE_ROW_PX,
81
+ tubeMapGeneRows,
82
+ tubeMapGenes,
83
+ } from '@jbrowse/bandage-core/tubeMap/genes'
57
84
  import {
58
85
  axisScaleOf,
59
86
  contains,
@@ -130,6 +157,7 @@ import type { GeneModel } from '@jbrowse/bandage-core/genes/genePins'
130
157
  import type { LayoutModeValue } from '@jbrowse/bandage-core/layoutModes'
131
158
  import type { NodeWidth } from '@jbrowse/bandage-core/nodeWidths'
132
159
  import type { Bounds, EngineRequest } from '@jbrowse/bandage-core/pipeline'
160
+ import type { AssemblyNames } from '@jbrowse/bandage-core/reference'
133
161
  import type {
134
162
  RenderBatch,
135
163
  Renderer,
@@ -155,6 +183,21 @@ const MAX_CANVAS_HEIGHT = 600
155
183
  // leaves room to hover a node and read its tooltip.
156
184
  const MIN_CANVAS_HEIGHT = 160
157
185
  const VARIANT_MAP_HEIGHT = 340
186
+ // The thinnest a fit draws a tube map's tubes
187
+ const MIN_FIT_TUBE_PX = 5
188
+
189
+ const TUBE_MAP_MODES = new Set<string>(['tubemap', 'tubemapref'])
190
+
191
+ // The sizes a tube map folds variants under. MICB's 22 kb cut draws 475
192
+ // columns whole, 34 under 3 bp and one under 50, where only its structural
193
+ // variants would stand: human windows are mostly SNPs.
194
+ const TUBE_MAP_FOLDS = [
195
+ { bp: 0, label: 'None' },
196
+ { bp: 3, label: 'Under 3 bp' },
197
+ { bp: 10, label: 'Under 10 bp' },
198
+ { bp: 50, label: 'Under 50 bp, leaving structural variants' },
199
+ { bp: 1000, label: 'Under 1 kb' },
200
+ ]
158
201
 
159
202
  const SEGMENTS_SUFFIX = '.segs.bed.gz'
160
203
 
@@ -374,6 +417,10 @@ export function GraphPaneMixin() {
374
417
  // The session's genes drawn onto the backbone: exons along the nodes
375
418
  // that carry them, names pinned at their midpoints. See genes/.
376
419
  showGenes: types.optional(types.boolean, true),
420
+ // Variants under this many bp fold into the reference before a tube map
421
+ // is laid out, each kept as a tick on its walk's tube (coarsen.ts); 0
422
+ // draws every one
423
+ tubeMapFold: types.optional(types.number, 0),
377
424
  // Which track the genes come from; empty picks the assembly's
378
425
  // annotation track (pickGeneTrack).
379
426
  geneTrackId: types.optional(types.string, ''),
@@ -441,6 +488,11 @@ export function GraphPaneMixin() {
441
488
  graph: undefined as Graph | undefined,
442
489
  // the reference window the graph on screen was cut for, set with it
443
490
  graphRegion: undefined as SubgraphRegion | undefined,
491
+ // The walk the track's graph lies on for the region's assembly, which
492
+ // the track's config declares; x starts along it unless the user chose
493
+ // another. Undefined for an rGFA, whose backbone is fixed, and for a
494
+ // graph with no region.
495
+ loadedReferencePath: undefined as string | undefined,
444
496
  layoutResult: undefined as LayoutResult | undefined,
445
497
  // what the legends in the pane's top-right corner measure, so no label
446
498
  // is placed under them
@@ -552,10 +604,18 @@ export function GraphPaneMixin() {
552
604
  get paneWidth() {
553
605
  return getContainingView(self).width
554
606
  },
607
+ // The graph the drawing's node ids address: a folded tube map's coarse
608
+ // graph, the cut otherwise. Hover, details and labels read it, so a
609
+ // merged node reports its own span and length.
610
+ get drawnGraph() {
611
+ return self.layoutResult?.tubeMap?.coarse?.graph ?? self.graph
612
+ },
613
+ }))
614
+ .views(self => ({
555
615
  get nodeById() {
556
- if (self.graph) {
616
+ if (self.drawnGraph) {
557
617
  const m = new Map<string, GraphNode>()
558
- for (const n of self.graph.nodes) {
618
+ for (const n of self.drawnGraph.nodes) {
559
619
  m.set(n.id, n)
560
620
  }
561
621
  return m
@@ -568,7 +628,7 @@ export function GraphPaneMixin() {
568
628
  // allocation on a 30k-node cut).
569
629
  get nodeLengths() {
570
630
  const m = new Map<string, number>()
571
- for (const n of self.graph?.nodes ?? []) {
631
+ for (const n of self.drawnGraph?.nodes ?? []) {
572
632
  m.set(n.id, n.length)
573
633
  }
574
634
  return m
@@ -805,14 +865,71 @@ export function GraphPaneMixin() {
805
865
  get selectedRepeat() {
806
866
  return self.repeatChoices.find(r => r.key === self.repeatKey)
807
867
  },
868
+ get backbone() {
869
+ return self.graph ? graphBackbone(self.graph) : undefined
870
+ },
871
+ // An assembly by each name a backbone may spell it with: the session's
872
+ // name and aliases, and the PanSN prefix the source track maps each of
873
+ // those to.
874
+ assemblySpellings(assemblyName: string): AssemblyNames {
875
+ const { assemblyManager } = getSession(self)
876
+ const assembly = assemblyManager.has(assemblyName)
877
+ ? assemblyManager.get(assemblyName)
878
+ : undefined
879
+ const panSN = (self.sourceAdapter?.assemblyNameToPanSN ?? {}) as Record<
880
+ string,
881
+ string
882
+ >
883
+ const names = [
884
+ assemblyName,
885
+ ...(assembly ? [assembly.name, ...assembly.aliases] : []),
886
+ ]
887
+ return {
888
+ name: assemblyName,
889
+ aliases: [...names, ...names.flatMap(n => panSN[n] ?? [])],
890
+ }
891
+ },
892
+ // Whether x lies along the reference the track's graph came with, not a
893
+ // walk "Draw x along" picked. An rGFA's backbone is fixed.
894
+ get drawsLoadedReference() {
895
+ const graph = self.graph
896
+ return (
897
+ graph?.anchoredBy === 'tags' ||
898
+ (graph?.referencePath !== undefined &&
899
+ graph.referencePath === self.loadedReferencePath)
900
+ )
901
+ },
902
+ }))
903
+ .views(self => ({
904
+ // the assembly the genes were read for
905
+ get geneAssembly() {
906
+ const region = self.graphRegion
907
+ return region ? self.assemblySpellings(region.assemblyName) : undefined
908
+ },
909
+ }))
910
+ .views(self => ({
911
+ // The genes on the drawn backbone under its own refNames. The track's
912
+ // config puts its reference on the cut's assembly, whatever the walk's
913
+ // name. After "Draw x along" another walk, the backbone takes the genes
914
+ // only where its PanSN prefix names their assembly.
915
+ get backboneGenes() {
916
+ const { backbone, geneAssembly, geneFeatures } = self
917
+ const binds =
918
+ self.drawsLoadedReference ||
919
+ (geneAssembly !== undefined &&
920
+ backboneAssembly(backbone, [geneAssembly]) !== undefined)
921
+ return backbone && geneFeatures && binds
922
+ ? featuresOnBackbone(geneFeatures, backbone)
923
+ : undefined
924
+ },
808
925
  }))
809
926
  .views(self => ({
810
927
  get nodeNeighbors() {
811
- return self.graph ? buildNeighbors(self.graph) : undefined
928
+ return self.drawnGraph ? buildNeighbors(self.drawnGraph) : undefined
812
929
  },
813
930
  get nodeInk(): NodeInk {
814
931
  return nodeInk(
815
- self.graph,
932
+ self.drawnGraph,
816
933
  self.nodeById,
817
934
  self.contigThickness,
818
935
  self.nodeWidth,
@@ -853,7 +970,20 @@ export function GraphPaneMixin() {
853
970
  a.label.localeCompare(b.label),
854
971
  )
855
972
  : bars.rows
856
- return { ...bars, rows: withCalls(rows, repeat?.calls) }
973
+ const [reference, ...paired] = withCalls(
974
+ [bars.reference, ...rows],
975
+ repeat?.calls,
976
+ )
977
+ return { ...bars, reference: reference!, rows: paired }
978
+ },
979
+ // The units walk rows paint every bar by, once the selected repeat's
980
+ // record states a drawn walk's runs.
981
+ get walkRowUnits() {
982
+ const bars = this.walkRowBars
983
+ return bars &&
984
+ [bars.reference, ...bars.rows].some(row => row.call?.sequences)
985
+ ? self.selectedRepeat?.units
986
+ : undefined
857
987
  },
858
988
  // The labels drawn beside the rows. Walk rows label from the bars
859
989
  // themselves, which follow the selected repeat and sample filter that
@@ -867,6 +997,14 @@ export function GraphPaneMixin() {
867
997
  }))
868
998
  : self.rowLabels
869
999
  },
1000
+ // The row labels are pinned to the pane's left edge, so the fit starts
1001
+ // the drawing past the widest one.
1002
+ get fitPadLeft() {
1003
+ return Math.max(
1004
+ FIT_PADDING,
1005
+ ...this.drawnRowLabels.map(r => rowLabelBox(r.label, 0).x1 + 6),
1006
+ )
1007
+ },
870
1008
  // Each bubble in the window with what it is, for the variant map's
871
1009
  // glyphs on the reference line.
872
1010
  get bubbleGlyphs() {
@@ -886,9 +1024,9 @@ export function GraphPaneMixin() {
886
1024
  self.chosenLayoutMode !== 'walkrows' &&
887
1025
  !self.layoutResult?.tubeMap &&
888
1026
  self.graph &&
889
- self.geneFeatures &&
1027
+ self.backboneGenes &&
890
1028
  positions
891
- ? genePins(self.graph, self.geneFeatures, positions)
1029
+ ? genePins(self.graph, self.backboneGenes, positions)
892
1030
  : []
893
1031
  },
894
1032
  // The same bubbles over every other layout, as halos along their nodes.
@@ -990,54 +1128,6 @@ export function GraphPaneMixin() {
990
1128
  })
991
1129
  : undefined
992
1130
  },
993
- // The pane is as tall as the drawing, rather than a fixed box the drawing
994
- // floats in the middle of.
995
- //
996
- // A row layout says how tall it is outright: rows are px, so the drawing's
997
- // height is the row count times the pitch and this is a sum, not a
998
- // derivation. It used to be derived from the drawing's ASPECT RATIO
999
- // against the x-fit scale, because y was in bp and a height in px did not
1000
- // exist until a scale was chosen — which is also why a ceiling had to be
1001
- // put on the row pitch to stop tall graphs from binding the fit on the
1002
- // wrong axis. Both are gone with the unit.
1003
- //
1004
- // An isotropic layout still has no height of its own and keeps the aspect
1005
- // derivation. It reads neither `scale` nor the height it is replacing, so
1006
- // zoomToFit consumes this without feeding back into it.
1007
- get canvasHeight() {
1008
- const bounds = this.layoutBounds
1009
- const usableWidth = self.paneWidth - FIT_PADDING * 2
1010
- // `paneHeight` replaces the built-in ceiling rather than adding a
1011
- // second clamp under it, and the floor still wins: a pane shorter than
1012
- // MIN_CANVAS_HEIGHT leaves no room to hover a node and read its
1013
- // tooltip, which is the reason that floor exists.
1014
- const ceiling = Math.max(
1015
- MIN_CANVAS_HEIGHT,
1016
- self.paneHeight ?? MAX_CANVAS_HEIGHT,
1017
- )
1018
- if (!bounds) {
1019
- return ceiling
1020
- }
1021
- if (self.pixelRows) {
1022
- // The variant map's drawing is one line; its glyphs and labels are
1023
- // painted above it by the overlay and need the room a row layout
1024
- // would give to rows.
1025
- const floor =
1026
- self.chosenLayoutMode === 'variants'
1027
- ? VARIANT_MAP_HEIGHT
1028
- : MIN_CANVAS_HEIGHT
1029
- return Math.min(ceiling, Math.max(floor, bounds.h + FIT_PADDING * 2))
1030
- }
1031
- return bounds.w > 0 && usableWidth > 0
1032
- ? Math.min(
1033
- ceiling,
1034
- Math.max(
1035
- MIN_CANVAS_HEIGHT,
1036
- (bounds.h * usableWidth) / bounds.w + FIT_PADDING * 2,
1037
- ),
1038
- )
1039
- : ceiling
1040
- },
1041
1131
  }))
1042
1132
  .views(self => ({
1043
1133
  get geneTrack() {
@@ -1067,7 +1157,7 @@ export function GraphPaneMixin() {
1067
1157
  // neighbour walk to say what the first one already worked out.
1068
1158
  get referenceRampDomain() {
1069
1159
  const ramp = self.referenceRamp
1070
- return ramp
1160
+ return ramp && !self.walkRowUnits
1071
1161
  ? { start: ramp.start, end: ramp.start + ramp.span }
1072
1162
  : undefined
1073
1163
  },
@@ -1235,18 +1325,29 @@ export function GraphPaneMixin() {
1235
1325
  return isLinearHost(view) ? view : undefined
1236
1326
  },
1237
1327
  }))
1328
+ .views(self => ({
1329
+ // Whether the drawing's x is reference bp a host can place. Walk rows
1330
+ // put the backbone at its bp too, but their bars are lengths, so the
1331
+ // mode's `cutMargins` is what says the drawing is a picture.
1332
+ get xIsReferenceBp() {
1333
+ return (
1334
+ self.layoutResult?.referenceAxis === true &&
1335
+ layoutModeByValue(self.chosenLayoutMode).cutMargins
1336
+ )
1337
+ },
1338
+ }))
1238
1339
  .views(self => ({
1239
1340
  // Whether the host places x. Only a layout whose x is reference bp can
1240
1341
  // take the window's transform; force, ordered and walk rows draw in
1241
1342
  // their own coordinates inside the track, and a popped bubble is a
1242
1343
  // picture of its own.
1243
1344
  get hostPlacesX() {
1244
- const { host, graphRegion: region, layoutResult: layout } = self
1345
+ const { host, graphRegion: region } = self
1245
1346
  return (
1246
1347
  host !== undefined &&
1247
1348
  host.initialized &&
1248
1349
  region !== undefined &&
1249
- layout?.referenceAxis === true &&
1350
+ self.xIsReferenceBp &&
1250
1351
  self.popStack.length === 0 &&
1251
1352
  !host.dynamicBlocks.contentBlocks.some(
1252
1353
  b => b.refName === region.refName && b.reversed,
@@ -1255,9 +1356,6 @@ export function GraphPaneMixin() {
1255
1356
  },
1256
1357
  }))
1257
1358
  .views(self => ({
1258
- get overlayLabels() {
1259
- return layoutLabels(self)
1260
- },
1261
1359
  get hostFrame() {
1262
1360
  const { host, graphRegion } = self
1263
1361
  return self.hostPlacesX && host && graphRegion
@@ -1268,6 +1366,127 @@ export function GraphPaneMixin() {
1268
1366
  const drawing = self.layoutResult?.tubeMap
1269
1367
  return drawing ? tubeMapPicture(drawing.layout) : undefined
1270
1368
  },
1369
+ get tubeMapReference() {
1370
+ const drawing = self.layoutResult?.tubeMap
1371
+ return drawing && self.drawnGraph
1372
+ ? referenceBoxes(self.drawnGraph, drawing.layout)
1373
+ : undefined
1374
+ },
1375
+ // the folded variants, as ticks on the tubes of the walks carrying them
1376
+ get tubeMapDeviations() {
1377
+ const coarse = self.layoutResult?.tubeMap?.coarse
1378
+ const layout = self.layoutResult?.tubeMap?.layout
1379
+ return coarse && layout
1380
+ ? deviationMarks(coarse.graph, layout, coarse.deviations)
1381
+ : []
1382
+ },
1383
+ // the reference boxes a linear view's connectors tie to its bp, which
1384
+ // the reference axis already puts under that bp
1385
+ get tubeMapReferenceNodes() {
1386
+ const layout = self.layoutResult
1387
+ return layout?.tubeMap && !layout.referenceAxis && self.drawnGraph
1388
+ ? referenceNodes(self.drawnGraph, layout.tubeMap.layout)
1389
+ : undefined
1390
+ },
1391
+ }))
1392
+ .views(self => ({
1393
+ // A linear view has the genes in a track of their own, at their bp
1394
+ get tubeMapGenes() {
1395
+ const reference = self.tubeMapReference
1396
+ return self.showGenes && !self.host && reference && self.backboneGenes
1397
+ ? tubeMapGenes(reference, self.backboneGenes)
1398
+ : []
1399
+ },
1400
+ }))
1401
+ .views(self => ({
1402
+ // `paneHeight` replaces the built-in ceiling rather than adding a second
1403
+ // clamp under it, and the floor still wins: a pane shorter than
1404
+ // MIN_CANVAS_HEIGHT leaves no room to hover a node and read its tooltip,
1405
+ // which is the reason that floor exists.
1406
+ get paneCeiling() {
1407
+ return Math.max(MIN_CANVAS_HEIGHT, self.paneHeight ?? MAX_CANVAS_HEIGHT)
1408
+ },
1409
+ // Room over a tube map for the rows its genes need, one inside the
1410
+ // padding and one more for each further gene that overlaps it
1411
+ get fitPadTop() {
1412
+ const rows = tubeMapGeneRows(self.tubeMapGenes)
1413
+ return FIT_PADDING + Math.max(0, rows - 1) * GENE_ROW_PX
1414
+ },
1415
+ // A tube map on its own axis reads by panning along it, as in
1416
+ // sequenceTubeMap. Rather than shrink a long cut to a strip, the fit
1417
+ // stops where its tubes are MIN_FIT_TUBE_PX wide, or at whatever fits
1418
+ // the tallest pane, with the cut's left end on screen.
1419
+ get minFitScale() {
1420
+ const bounds = self.layoutBounds
1421
+ const layout = self.layoutResult
1422
+ const tubePx = layout?.tubeMap?.layout.tracks[0]?.width
1423
+ return bounds && bounds.h > 0 && tubePx && !layout.referenceAxis
1424
+ ? Math.min(
1425
+ MIN_FIT_TUBE_PX / tubePx,
1426
+ (this.paneCeiling - this.fitPadTop - FIT_PADDING) / bounds.h,
1427
+ )
1428
+ : 0
1429
+ },
1430
+ // The pane is as tall as the drawing, rather than a fixed box the drawing
1431
+ // floats in. A row layout's rows are px, so its height is a sum; an
1432
+ // isotropic layout has no height of its own and takes its aspect ratio
1433
+ // at the width's fit. Neither reads `scale`, so the fit reads this
1434
+ // without feeding back into it.
1435
+ get canvasHeight() {
1436
+ const bounds = self.layoutBounds
1437
+ const usableWidth = self.paneWidth - FIT_PADDING * 2
1438
+ const ceiling = this.paneCeiling
1439
+ if (!bounds) {
1440
+ return ceiling
1441
+ }
1442
+ if (self.pixelRows) {
1443
+ // The variant map's drawing is one line; its glyphs and labels are
1444
+ // painted above it by the overlay and need the room a row layout
1445
+ // would give to rows.
1446
+ const floor =
1447
+ self.chosenLayoutMode === 'variants'
1448
+ ? VARIANT_MAP_HEIGHT
1449
+ : MIN_CANVAS_HEIGHT
1450
+ return Math.min(
1451
+ ceiling,
1452
+ Math.max(floor, bounds.h + this.fitPadTop + FIT_PADDING),
1453
+ )
1454
+ }
1455
+ return bounds.w > 0 && usableWidth > 0
1456
+ ? Math.min(
1457
+ ceiling,
1458
+ Math.max(
1459
+ MIN_CANVAS_HEIGHT,
1460
+ bounds.h * Math.max(usableWidth / bounds.w, this.minFitScale) +
1461
+ this.fitPadTop +
1462
+ FIT_PADDING,
1463
+ ),
1464
+ )
1465
+ : ceiling
1466
+ },
1467
+ // Where the fit puts the drawing, or undefined until there is a layout
1468
+ // and a measured canvas to fit it into
1469
+ get fittedTransform() {
1470
+ const bounds = self.layoutBounds
1471
+ return bounds
1472
+ ? fitTransform(
1473
+ bounds,
1474
+ self.paneWidth,
1475
+ this.canvasHeight,
1476
+ self.pixelRows,
1477
+ {
1478
+ minScale: this.minFitScale,
1479
+ padLeft: self.fitPadLeft,
1480
+ padTop: this.fitPadTop,
1481
+ },
1482
+ )
1483
+ : undefined
1484
+ },
1485
+ }))
1486
+ .views(self => ({
1487
+ get overlayLabels() {
1488
+ return layoutLabels(self)
1489
+ },
1271
1490
  get tubeMapFrame() {
1272
1491
  const drawing = self.layoutResult?.tubeMap
1273
1492
  return drawing
@@ -1276,16 +1495,75 @@ export function GraphPaneMixin() {
1276
1495
  translateX: self.translateX,
1277
1496
  scaleY: self.scaleY,
1278
1497
  translateY: self.translateY,
1279
- usableHeight: self.canvasHeight - FIT_PADDING * 2,
1498
+ usableHeight: self.canvasHeight - self.fitPadTop - FIT_PADDING,
1280
1499
  })
1281
1500
  : undefined
1282
1501
  },
1283
1502
  }))
1284
1503
  .views(self => ({
1504
+ // the reference ruler under the tubes, unless the linear view's ruler
1505
+ // is already the axis
1506
+ get tubeMapRulerBoxes() {
1507
+ const reference = self.tubeMapReference
1508
+ return reference && !self.hostPlacesX
1509
+ ? rulerBoxes(reference)
1510
+ : undefined
1511
+ },
1512
+ // The connectors run from the top of the pane down to the tubes' top
1513
+ get connectorZoneBottom() {
1514
+ const picture = self.tubeMapPicture
1515
+ const frame = self.tubeMapFrame
1516
+ return picture && frame ? frame.y(picture.bounds.minY) - 2 : 0
1517
+ },
1518
+ // Read off the live blocks, so the bands' tops follow every frame of a
1519
+ // pan in the linear view and their bottoms every pan of the tubes
1520
+ get tubeMapConnectors() {
1521
+ const nodes = self.tubeMapReferenceNodes
1522
+ const frame = self.tubeMapFrame
1523
+ const { host, graphRegion } = self
1524
+ const bp =
1525
+ nodes && frame && host?.initialized && graphRegion
1526
+ ? hostFrame(host, graphRegion)
1527
+ : undefined
1528
+ return nodes && frame && bp
1529
+ ? tubeMapConnectors(
1530
+ nodes,
1531
+ b => b * bp.scale + bp.translateX,
1532
+ frame.x,
1533
+ self.paneWidth,
1534
+ )
1535
+ : []
1536
+ },
1537
+ }))
1538
+ .views(self => ({
1539
+ // What the tube map's legend has to explain: a zigzag where the ruler
1540
+ // squeezes a box on screen, and the fold its walks' ticks stand for
1541
+ get tubeMapKeys() {
1542
+ const boxes = self.tubeMapRulerBoxes
1543
+ const frame = self.tubeMapFrame
1544
+ const marks =
1545
+ boxes && frame
1546
+ ? rulerMarks(boxes, frame.x, self.paneWidth, () => 0)
1547
+ : undefined
1548
+ return {
1549
+ squeezed: (marks?.squeezed.length ?? 0) > 0,
1550
+ foldBp:
1551
+ self.tubeMapDeviations.length > 0 ? self.tubeMapFold : undefined,
1552
+ }
1553
+ },
1285
1554
  tubeMapNodeAt(sx: number, sy: number) {
1286
1555
  const drawing = self.layoutResult?.tubeMap
1287
1556
  const frame = self.tubeMapFrame
1288
- return drawing && frame ? tubeMapNodeAt(drawing, frame, sx, sy) : null
1557
+ return (
1558
+ (drawing && frame ? tubeMapNodeAt(drawing, frame, sx, sy) : null) ??
1559
+ connectorAt(
1560
+ self.tubeMapConnectors,
1561
+ self.connectorZoneBottom,
1562
+ sx,
1563
+ sy,
1564
+ ) ??
1565
+ null
1566
+ )
1289
1567
  },
1290
1568
  }))
1291
1569
  .actions(self => ({
@@ -1366,6 +1644,9 @@ export function GraphPaneMixin() {
1366
1644
  setShowGenes(show: boolean) {
1367
1645
  self.showGenes = show
1368
1646
  },
1647
+ setTubeMapFold(bp: number) {
1648
+ self.tubeMapFold = bp
1649
+ },
1369
1650
  setGeneTrackId(trackId: string) {
1370
1651
  self.geneTrackId = trackId
1371
1652
  },
@@ -1523,10 +1804,10 @@ export function GraphPaneMixin() {
1523
1804
  self.positionsVersion++
1524
1805
  },
1525
1806
  zoomToFit() {
1526
- const bounds = self.layoutBounds
1527
- const usableHeight = self.canvasHeight - FIT_PADDING * 2
1528
1807
  // A host owns x, so a fit while hosted places the rows only.
1529
1808
  if (self.viewportOwner === 'host') {
1809
+ const bounds = self.layoutBounds
1810
+ const usableHeight = self.canvasHeight - FIT_PADDING * 2
1530
1811
  if (bounds && usableHeight > 0) {
1531
1812
  self.translateY = fittedTranslateY(
1532
1813
  bounds,
@@ -1536,17 +1817,10 @@ export function GraphPaneMixin() {
1536
1817
  }
1537
1818
  return
1538
1819
  }
1539
- // Nothing to fit into before the canvas is measured. The autorun re-runs
1540
- // once width lands, so skipping beats persisting a negative scale into
1541
- // the session snapshot.
1542
- const fit = bounds
1543
- ? fitTransform(
1544
- bounds,
1545
- self.paneWidth,
1546
- self.canvasHeight,
1547
- self.pixelRows,
1548
- )
1549
- : undefined
1820
+ // Nothing to fit into before the canvas is measured. The fit autorun
1821
+ // runs again once width lands, so skipping beats persisting a negative
1822
+ // scale into the session snapshot.
1823
+ const fit = self.fittedTransform
1550
1824
  if (fit) {
1551
1825
  self.scale = fit.scale
1552
1826
  self.translateX = fit.translateX
@@ -1580,7 +1854,7 @@ export function GraphPaneMixin() {
1580
1854
  // whose x no longer means bp is refit.
1581
1855
  releaseHost() {
1582
1856
  if (self.viewportOwner === 'host') {
1583
- if (self.layoutResult?.referenceAxis) {
1857
+ if (self.xIsReferenceBp) {
1584
1858
  self.viewportOwner = 'user'
1585
1859
  } else {
1586
1860
  self.viewportOwner = 'fit'
@@ -1688,13 +1962,24 @@ export function GraphPaneMixin() {
1688
1962
  // 'force' is expressed. See LAYOUT_MODES.
1689
1963
  function* computeLayout(graph: Graph) {
1690
1964
  const start = performance.now()
1965
+ // reads are placed by the cut's segment names, which a fold renames
1966
+ const coarse =
1967
+ TUBE_MAP_MODES.has(self.chosenLayoutMode) &&
1968
+ self.tubeMapFold > 0 &&
1969
+ !graph.reads
1970
+ ? coarsenTubeMap(graph, self.tubeMapFold)
1971
+ : undefined
1691
1972
  const local = layoutModeByValue(self.chosenLayoutMode).run(
1692
- graph,
1973
+ coarse?.graph ?? graph,
1693
1974
  self.graphRegion,
1694
1975
  self.host ? self.layoutResult?.sampleRows : undefined,
1695
1976
  )
1696
1977
  if (local) {
1697
- return { result: local, duration: performance.now() - start }
1978
+ const result =
1979
+ coarse && local.tubeMap
1980
+ ? { ...local, tubeMap: { ...local.tubeMap, coarse } }
1981
+ : local
1982
+ return { result, duration: performance.now() - start }
1698
1983
  }
1699
1984
  const cache = forceLayoutsOf(graph)
1700
1985
  const key = forceLayoutKey(graph)
@@ -1761,6 +2046,20 @@ export function GraphPaneMixin() {
1761
2046
  return live
1762
2047
  }
1763
2048
 
2049
+ // The walk a path graph's track puts on the region's assembly: the one
2050
+ // that names it, else the one chooseReferencePath infers
2051
+ function loadedReference(graph: Graph, region: SubgraphRegion) {
2052
+ const paths = graph.anchorPaths
2053
+ return graph.anchoredBy === 'paths' && paths
2054
+ ? (
2055
+ assemblyWalk(
2056
+ graph,
2057
+ self.assemblySpellings(region.assemblyName),
2058
+ ) ?? chooseReferencePath(paths, region.assemblyName)
2059
+ )?.name
2060
+ : undefined
2061
+ }
2062
+
1764
2063
  // `keepSelection` for a re-cut of the same source: node ids survive one
1765
2064
  // where edge indexes do not, so the selection is found again by id.
1766
2065
  // `readsOf` fetches the reads over the parsed graph before its one
@@ -1774,10 +2073,17 @@ export function GraphPaneMixin() {
1774
2073
  ) {
1775
2074
  const signal = loadController?.signal
1776
2075
  self.setStatusMessage('Parsing GFA')
1777
- const graph = loadGraph(text, name, {
2076
+ const parsed = loadGraph(text, name, {
1778
2077
  referencePath: self.referencePath || region?.assemblyName,
1779
2078
  maxNodes: self.maxGraphNodes,
1780
2079
  })
2080
+ const loaded = region ? loadedReference(parsed, region) : undefined
2081
+ const graph =
2082
+ !self.referencePath &&
2083
+ loaded !== undefined &&
2084
+ loaded !== parsed.referencePath
2085
+ ? anchorFromPaths(parsed, loaded)
2086
+ : parsed
1781
2087
  if (readsOf) {
1782
2088
  self.setStatusMessage('Reading alignments')
1783
2089
  try {
@@ -1800,6 +2106,7 @@ export function GraphPaneMixin() {
1800
2106
  const selected = keepSelection ? self.selectedNode : null
1801
2107
  self.graph = graph
1802
2108
  self.graphRegion = region
2109
+ self.loadedReferencePath = loaded
1803
2110
  self.indexBubbles = undefined
1804
2111
  self.geneFeatures = undefined
1805
2112
  self.repeatArrays = undefined
@@ -1987,6 +2294,7 @@ export function GraphPaneMixin() {
1987
2294
  abortLoad()
1988
2295
  self.graph = undefined
1989
2296
  self.graphRegion = undefined
2297
+ self.loadedReferencePath = undefined
1990
2298
  self.layoutResult = undefined
1991
2299
  self.indexBubbles = undefined
1992
2300
  self.geneFeatures = undefined
@@ -2225,16 +2533,16 @@ export function GraphPaneMixin() {
2225
2533
  startRenderingBackend(backend: Renderer) {
2226
2534
  if (!self.autorunsInstalled) {
2227
2535
  // Autorun: keep the view fitted to the graph until the user moves it.
2228
- // Reads layoutResult plus (via zoomToFit) width/canvasHeight, so it
2229
- // re-fires — and re-fits — as the layout arrives and the canvas is
2230
- // measured, rather than firing once against not-yet-known dimensions.
2231
- // A manual pan/zoom (or a restored-session transform) makes the
2232
- // viewport the user's, and a host makes it the linear view's.
2536
+ // Reading fittedTransform tracks everything the fit depends on, so
2537
+ // it re-fits as the layout arrives, the canvas is measured and the
2538
+ // genes over a tube map claim their rows. A manual pan/zoom (or a
2539
+ // restored-session transform) makes the viewport the user's, and a
2540
+ // host makes it the linear view's.
2233
2541
  addDisposer(
2234
2542
  self,
2235
2543
  autorun(() => {
2236
2544
  if (
2237
- self.layoutResult &&
2545
+ self.fittedTransform &&
2238
2546
  untracked(() => self.viewportOwner === 'fit')
2239
2547
  ) {
2240
2548
  self.zoomToFit()
@@ -2595,6 +2903,23 @@ export function GraphPaneMixin() {
2595
2903
  },
2596
2904
  ]
2597
2905
  : []),
2906
+ ...(TUBE_MAP_MODES.has(self.chosenLayoutMode)
2907
+ ? [
2908
+ {
2909
+ label: 'Fold variants',
2910
+ subMenu: TUBE_MAP_FOLDS.map(({ bp, label }) => ({
2911
+ type: 'radio' as const,
2912
+ label,
2913
+ checked: self.tubeMapFold === bp,
2914
+ disabled: bp > 0 && self.graph?.reads !== undefined,
2915
+ onClick: () => {
2916
+ self.setTubeMapFold(bp)
2917
+ void self.recomputeLayout()
2918
+ },
2919
+ })),
2920
+ },
2921
+ ]
2922
+ : []),
2598
2923
  ...(self.chosenLayoutMode === 'walkrows' &&
2599
2924
  self.repeatChoices.length > 0
2600
2925
  ? [