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
@@ -0,0 +1,39 @@
1
+ import { REFERENCE_RAMP_MAX_HUE } from '@jbrowse/bandage-core/renderer/GeometryBuilder'
2
+
3
+ // The reference-position ramp in CSS, from the same three numbers
4
+ // `getNodeColor` paints with (hue 0 to REFERENCE_RAMP_MAX_HUE at 70%/50%), so
5
+ // a key or an overlay cannot come to describe a ramp the canvas stopped using.
6
+
7
+ export function rampHueCss(hue: number) {
8
+ return `hsl(${hue}, 70%, 50%)`
9
+ }
10
+
11
+ export function rampHue(bp: number, domain: { start: number; end: number }) {
12
+ const frac = (bp - domain.start) / (domain.end - domain.start)
13
+ return Math.max(0, Math.min(1, frac)) * REFERENCE_RAMP_MAX_HUE
14
+ }
15
+
16
+ // hue degrees one gradient stop spans, since SVG interpolates stops in RGB
17
+ const HUE_STOP_DEG = 30
18
+
19
+ // The hues a gradient across `bp` of reference paints, evenly spaced along it
20
+ // and walked from `to`'s end when reversed. One hue means the span reads flat.
21
+ export function rampStops(
22
+ { start, bp, reversed }: { start: number; bp: number; reversed?: boolean },
23
+ domain: { start: number; end: number },
24
+ ) {
25
+ const low = rampHue(start, domain)
26
+ const high = rampHue(start + bp, domain)
27
+ if (high - low < 1) {
28
+ return [(low + high) / 2]
29
+ }
30
+ const n = Math.ceil((high - low) / HUE_STOP_DEG)
31
+ return Array.from({ length: n + 1 }, (_, i) =>
32
+ rampHue(start + (bp * (reversed ? n - i : i)) / n, domain),
33
+ )
34
+ }
35
+
36
+ export const RAMP_GRADIENT_CSS = `linear-gradient(to right, ${Array.from(
37
+ { length: 7 },
38
+ (_, i) => `${rampHueCss((i / 6) * REFERENCE_RAMP_MAX_HUE)} ${(i / 6) * 100}%`,
39
+ ).join(', ')})`
@@ -1,6 +1,10 @@
1
1
  import { genePins } from '@jbrowse/bandage-core/genes/genePins'
2
2
  import { convertGFAToGraph } from '@jbrowse/bandage-core/gfa/gfaConverter'
3
3
  import { parseGFA } from '@jbrowse/bandage-core/gfa-core/index'
4
+ import {
5
+ featuresOnBackbone,
6
+ graphBackbone,
7
+ } from '@jbrowse/bandage-core/reference'
4
8
 
5
9
  import { geneModelsFrom, pickGeneTrack } from './geneFeatures'
6
10
 
@@ -93,11 +97,14 @@ test('exons land on the backbone stretch they cover, and the name at the midpoin
93
97
  expect(pin!.covered).toBe(1)
94
98
  })
95
99
 
96
- test('a PanSN-named backbone still carries a gene named by its contig', () => {
100
+ test('a PanSN-named backbone carries a gene named by its contig once renamed onto it', () => {
97
101
  const pansn = convertGFAToGraph(
98
102
  parseGFA(GFA.replaceAll('SN:Z:chr1', 'SN:Z:GRCh38#0#chr1')),
99
103
  )
100
- expect(genePins(pansn, geneModelsFrom([gene]), positions)).toHaveLength(1)
104
+ const genes = geneModelsFrom([gene])
105
+ expect(genePins(pansn, genes, positions)).toEqual([])
106
+ const renamed = featuresOnBackbone(genes, graphBackbone(pansn)!)
107
+ expect(genePins(pansn, renamed, positions)).toHaveLength(1)
101
108
  })
102
109
 
103
110
  test('a gene on another sequence pins nothing', () => {
@@ -1,6 +1,6 @@
1
- import { cutHolds, hostCut } from './host'
1
+ import { cutHolds, hostCut, hostFrame } from './host'
2
2
 
3
- import type { HostWindow } from './host'
3
+ import type { HostWindow, LinearHost } from './host'
4
4
 
5
5
  const window: HostWindow = {
6
6
  refName: 'chr1',
@@ -39,3 +39,26 @@ test('a cut trimmed to a cap the window fills still holds that window', () => {
39
39
  expect(cut.end - cut.start).toBeLessThanOrEqual(60_000)
40
40
  expect(cutHolds(cut, window)).toBe(true)
41
41
  })
42
+
43
+ test('a reversed block runs bp right to left from the same screen edge', () => {
44
+ const block = {
45
+ refName: 'chr1',
46
+ assemblyName: 'hg38',
47
+ start: 1000,
48
+ end: 2000,
49
+ offsetPx: 500,
50
+ }
51
+ const view = (reversed: boolean) =>
52
+ ({
53
+ bpPerPx: 2,
54
+ offsetPx: 400,
55
+ dynamicBlocks: { contentBlocks: [{ ...block, reversed }] },
56
+ }) as unknown as LinearHost
57
+ const region = { refName: 'chr1', assemblyName: 'hg38', start: 0, end: 1 }
58
+ const at = (reversed: boolean, bp: number) => {
59
+ const frame = hostFrame(view(reversed), region)!
60
+ return bp * frame.scale + frame.translateX
61
+ }
62
+ expect([at(false, 1000), at(false, 2000)]).toEqual([100, 600])
63
+ expect([at(true, 2000), at(true, 1000)]).toEqual([100, 600])
64
+ })
@@ -65,7 +65,8 @@ export interface HostWindow {
65
65
  // x is reference bp on an anchored layout, so the graph shows what the linear
66
66
  // view shows when one bp is as many px in both and the cut's refName sits at
67
67
  // the same screen x. Undefined when the linear view shows no block of that
68
- // refName, which is a re-cut's job rather than the frame's.
68
+ // refName, which is a re-cut's job rather than the frame's. A reversed block
69
+ // runs bp right to left, which only the tube map's connectors can draw.
69
70
  export function hostFrame(view: LinearHost, region: SubgraphRegion) {
70
71
  const block = widest(
71
72
  view.dynamicBlocks.contentBlocks.filter(
@@ -74,12 +75,13 @@ export function hostFrame(view: LinearHost, region: SubgraphRegion) {
74
75
  ),
75
76
  )
76
77
  const { bpPerPx } = view
77
- return block && bpPerPx > 0
78
- ? {
79
- scale: 1 / bpPerPx,
80
- translateX: block.offsetPx - view.offsetPx - block.start / bpPerPx,
81
- }
82
- : undefined
78
+ if (!block || !(bpPerPx > 0)) {
79
+ return undefined
80
+ }
81
+ const left = block.offsetPx - view.offsetPx
82
+ return block.reversed
83
+ ? { scale: -1 / bpPerPx, translateX: left + block.end / bpPerPx }
84
+ : { scale: 1 / bpPerPx, translateX: left - block.start / bpPerPx }
83
85
  }
84
86
 
85
87
  // Read off the LIVE blocks even when the coarse ones woke the caller: those
@@ -4,6 +4,7 @@
4
4
  // "the submenu is called something else".
5
5
 
6
6
  import { spreadFor } from '@jbrowse/bandage-core/bubbleSpreads'
7
+ import { rowLabelBox } from '@jbrowse/bandage-core/graphLabels'
7
8
  import {
8
9
  PROPORTIONAL_LENGTH,
9
10
  bandageAutoScale,
@@ -21,6 +22,8 @@ import { applySnapshot, getSnapshot } from '@jbrowse/mobx-state-tree'
21
22
  import { MAX_GRAPH_REGION_BP, formatSpanBp } from './model'
22
23
  import stateModelFactory from './viewModel'
23
24
 
25
+ import type { LayoutModeValue } from '@jbrowse/bandage-core/layoutModes'
26
+ import type { Renderer } from '@jbrowse/bandage-core/renderer/types'
24
27
  import type { Graph } from '@jbrowse/bandage-core/types'
25
28
 
26
29
  const mockRpcCall = vi.fn()
@@ -46,7 +49,14 @@ const mockSession = {
46
49
  has: (name: string) => canonicalAssembly(name) !== undefined,
47
50
  get: (name: string) => {
48
51
  const canonical = canonicalAssembly(name)
49
- return canonical === undefined ? undefined : { name: canonical }
52
+ return canonical === undefined
53
+ ? undefined
54
+ : {
55
+ name: canonical,
56
+ aliases: Object.keys(mockSession.assemblyAliases).filter(
57
+ alias => mockSession.assemblyAliases[alias] === canonical,
58
+ ),
59
+ }
50
60
  },
51
61
  },
52
62
  views: [] as unknown[],
@@ -134,6 +144,18 @@ function rpcRespond() {
134
144
  })
135
145
  }
136
146
 
147
+ function fakeRenderer() {
148
+ return {
149
+ resize: () => {},
150
+ uploadGeometry: () => {},
151
+ updateTransform: () => {},
152
+ render: () => {},
153
+ setNodeHighlights: () => {},
154
+ setEdgeHighlight: () => {},
155
+ dispose: () => {},
156
+ } as unknown as Renderer
157
+ }
158
+
137
159
  function createModel() {
138
160
  return stateModelFactory().create({ type: 'GraphGenomeView' })
139
161
  }
@@ -1181,6 +1203,79 @@ describe('canvas height follows the drawing', () => {
1181
1203
  })
1182
1204
  })
1183
1205
 
1206
+ // A 22 kb cut of 1,295 nodes fitted whole drew at 3.4%, a strip of 1 px
1207
+ // tubes. On its own axis a tube map fits no smaller than 5 px tubes and is
1208
+ // panned along, as in sequenceTubeMap.
1209
+ describe('zoomToFit on a tube map', () => {
1210
+ // A SNP between each pair of backbone nodes, so the boxes cannot merge
1211
+ function snpChainGfa(snps: number) {
1212
+ const lines = ['H\tVN:Z:1.0', 'S\tb0\t*\tLN:i:1000']
1213
+ const ref = ['b0+']
1214
+ const alt = ['b0+']
1215
+ for (let i = 1; i <= snps; i++) {
1216
+ lines.push(
1217
+ `S\tr${i}\tA`,
1218
+ `S\ta${i}\tC`,
1219
+ `S\tb${i}\t*\tLN:i:1000`,
1220
+ `L\tb${i - 1}\t+\tr${i}\t+\t0M`,
1221
+ `L\tb${i - 1}\t+\ta${i}\t+\t0M`,
1222
+ `L\tr${i}\t+\tb${i}\t+\t0M`,
1223
+ `L\ta${i}\t+\tb${i}\t+\t0M`,
1224
+ )
1225
+ ref.push(`r${i}+`, `b${i}+`)
1226
+ alt.push(`a${i}+`, `b${i}+`)
1227
+ }
1228
+ lines.push(`P\tref\t${ref.join(',')}\t*`, `P\talt\t${alt.join(',')}\t*`)
1229
+ return `${lines.join('\n')}\n`
1230
+ }
1231
+
1232
+ async function tubeMap(snps: number) {
1233
+ const model = stateModelFactory().create({
1234
+ type: 'GraphGenomeView',
1235
+ layoutMode: 'tubemap',
1236
+ })
1237
+ await model.loadGFA(snpChainGfa(snps), `${snps} SNPs`)
1238
+ model.zoomToFit()
1239
+ return model
1240
+ }
1241
+
1242
+ function tubePx(model: Awaited<ReturnType<typeof tubeMap>>) {
1243
+ return model.layoutResult!.tubeMap!.layout.tracks[0]!.width * model.scale
1244
+ }
1245
+
1246
+ test('a cut too long for the pane opens at 5 px tubes, its left end on screen', async () => {
1247
+ const model = await tubeMap(100)
1248
+ const bounds = model.layoutBounds!
1249
+ expect(model.width - 80).toBeLessThan(bounds.w * model.scale)
1250
+
1251
+ expect(tubePx(model)).toBeCloseTo(5, 10)
1252
+ expect(bounds.minX * model.scale + model.translateX).toBeCloseTo(40, 5)
1253
+ expect(model.canvasHeight).toBe(Math.max(160, bounds.h * model.scale + 80))
1254
+ })
1255
+
1256
+ // An action's reads are untracked, so an autorun that called zoomToFit
1257
+ // refitted on a new layout and on nothing else
1258
+ test('a view the user has not moved refits when its pane widens', async () => {
1259
+ const model = await tubeMap(2)
1260
+ model.startRenderingBackend(fakeRenderer())
1261
+ const before = model.scale
1262
+
1263
+ model.setWidth(model.width * 2)
1264
+ expect(model.scale).toBeGreaterThan(before)
1265
+ })
1266
+
1267
+ test('a cut that fits with wider tubes still fits whole', async () => {
1268
+ const model = await tubeMap(2)
1269
+ const bounds = model.layoutBounds!
1270
+
1271
+ expect(tubePx(model)).toBeGreaterThan(5)
1272
+ const left = bounds.minX * model.scale + model.translateX
1273
+ const right = left + bounds.w * model.scale
1274
+ expect(left).toBeGreaterThanOrEqual(40 - 1e-6)
1275
+ expect(right).toBeLessThanOrEqual(model.width - 40 + 1e-6)
1276
+ })
1277
+ })
1278
+
1184
1279
  // hoveredEdge is an index into graph.edges, so it addresses the graph it was set
1185
1280
  // against; carrying it across a load pointed the tooltip and the highlight at
1186
1281
  // whatever ended up at that index in the new graph.
@@ -1949,13 +2044,16 @@ describe('a row layout draws y in pixels', () => {
1949
2044
  expect(model.canvasHeight).toBe(600)
1950
2045
  // the vertical fit is the tighter one, and is not the one taken
1951
2046
  expect((model.canvasHeight - 80) / model.layoutBounds!.h).toBeLessThan(
1952
- (model.width - 80) / model.layoutBounds!.w,
2047
+ (model.width - model.fitPadLeft - 40) / model.layoutBounds!.w,
1953
2048
  )
1954
2049
 
1955
2050
  const xs = Object.values(model.nodePositions!)
1956
2051
  .flat()
1957
2052
  .map(p => p.x * model.scaleX + model.translateX)
1958
- expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(model.width - 80, 5)
2053
+ expect(Math.max(...xs) - Math.min(...xs)).toBeCloseTo(
2054
+ model.width - model.fitPadLeft - 40,
2055
+ 5,
2056
+ )
1959
2057
  })
1960
2058
 
1961
2059
  // Rows are a track's row height, so zooming the sequence axis leaves them
@@ -2112,7 +2210,19 @@ describe('what the row axis draws, in pixels', () => {
2112
2210
  // in the path.
2113
2211
  test('the backbone spans the pane', async () => {
2114
2212
  const model = await fitted()
2115
- expect(spread(render(model).map(p => p.x))).toBeCloseTo(model.width - 80, 5)
2213
+ expect(spread(render(model).map(p => p.x))).toBeCloseTo(
2214
+ model.width - model.fitPadLeft - 40,
2215
+ 5,
2216
+ )
2217
+ })
2218
+
2219
+ test('the fit starts the drawing right of the row labels', async () => {
2220
+ const model = await fitted()
2221
+ const labelsEnd = Math.max(
2222
+ ...model.drawnRowLabels.map(r => rowLabelBox(r.label, 0).x1),
2223
+ )
2224
+ expect(labelsEnd).toBeGreaterThan(40)
2225
+ expect(Math.min(...render(model).map(p => p.x))).toBeGreaterThan(labelsEnd)
2116
2226
  })
2117
2227
 
2118
2228
  // Zoom is an x-only gesture on this axis, so afterwards the rows are drawn in
@@ -2495,3 +2605,272 @@ describe('annotation reads beside a cut', () => {
2495
2605
  expect(model.geneFeatures?.map(g => g.name)).toEqual(['XYZ2'])
2496
2606
  })
2497
2607
  })
2608
+
2609
+ // GRCh38 and CHM13 walk chr6 at overlapping coordinates, so hg38's HLA-A lies
2610
+ // over segment 1 whichever walk x is drawn along. Only GRCh38's is hg38's.
2611
+ describe('genes on a backbone of another assembly', () => {
2612
+ const SEGMENTS = [
2613
+ 'S\t1\tAAAAAAAAAA',
2614
+ 'S\t2\tCCCCCCCCCC',
2615
+ 'S\t3\tGGGGGGGGGG',
2616
+ 'S\t4\tTTTTTTTTTT',
2617
+ 'L\t1\t+\t2\t+\t0M',
2618
+ 'L\t1\t+\t3\t+\t0M',
2619
+ 'L\t2\t+\t4\t+\t0M',
2620
+ 'L\t3\t+\t4\t+\t0M',
2621
+ ]
2622
+ // A gbz-base cut writes the track's reference walk first
2623
+ const gfaOf = (...walks: string[]) => [...SEGMENTS, ...walks, ''].join('\n')
2624
+ const GRCH38 = 'W\tGRCh38\t0\tchr6\t1000\t1030\t>1>2>4'
2625
+ const CHM13 = 'W\tCHM13\t0\tchr6\t1005\t1035\t>1>3>4'
2626
+ const CHR6_WALKS = gfaOf(GRCH38, CHM13)
2627
+ // The same alleles named the way an older graph does, with no PanSN prefix to
2628
+ // say whose chr6 each is
2629
+ const BARE_CHR6_PATHS = gfaOf(
2630
+ 'P\tchr6:1000-1030\t1+,2+,4+\t*',
2631
+ 'P\tCHM13_chr6:1005-1035\t1+,3+,4+\t*',
2632
+ )
2633
+ const BARE_CHR6_RGFA = [
2634
+ 'S\t1\tAAAAAAAAAA\tSN:Z:chr6\tSO:i:1000\tSR:i:0',
2635
+ 'S\t2\tCCCCCCCCCC\tSN:Z:chr6\tSO:i:1010\tSR:i:0',
2636
+ 'S\t3\tGGGGGGGGGG\tSN:Z:CHM13_chr6\tSO:i:1015\tSR:i:1',
2637
+ 'S\t4\tTTTTTTTTTT\tSN:Z:chr6\tSO:i:1020\tSR:i:0',
2638
+ ...SEGMENTS.filter(line => line.startsWith('L')),
2639
+ '',
2640
+ ].join('\n')
2641
+ // chr6 bp that only GRCh38's walk covers, only CHM13's, and both
2642
+ const ON_GRCH38 = { start: 1000, end: 1004 }
2643
+ const ON_CHM13 = { start: 1031, end: 1034 }
2644
+ const ON_BOTH = { start: 1012, end: 1018 }
2645
+
2646
+ let gfa = CHR6_WALKS
2647
+ let geneAt = { refName: 'chr6', start: 1002, end: 1008 }
2648
+ const bySlot = (obj: Record<string, unknown>, key: string) => obj[key]
2649
+ const adapterOnly = (obj: Record<string, unknown>, key: string) =>
2650
+ key === 'adapter' ? obj.adapter : undefined
2651
+
2652
+ function sessionWith(assemblyName: string, graphAdapter = {}) {
2653
+ mockSession.tracks = [
2654
+ { trackId: 'graph', assemblyNames: [], adapter: graphAdapter },
2655
+ {
2656
+ trackId: 'refseq',
2657
+ name: 'RefSeq genes',
2658
+ assemblyNames: [assemblyName],
2659
+ adapter: { type: 'Gff3TabixAdapter' },
2660
+ },
2661
+ ]
2662
+ }
2663
+
2664
+ beforeEach(() => {
2665
+ vi.mocked(readConfObject).mockImplementation(bySlot)
2666
+ sessionWith('hg38')
2667
+ gfa = CHR6_WALKS
2668
+ geneAt = { refName: 'chr6', start: 1002, end: 1008 }
2669
+ mockRpcCall.mockReset()
2670
+ mockRpcCall.mockImplementation((_sid: unknown, method: string) =>
2671
+ method === 'GetSubgraph'
2672
+ ? Promise.resolve(gfa)
2673
+ : method === 'CoreGetFeatures'
2674
+ ? Promise.resolve([{ type: 'gene', name: 'HLA-A', ...geneAt }])
2675
+ : Promise.reject(new Error(`Unexpected RPC: ${method}`)),
2676
+ )
2677
+ })
2678
+
2679
+ afterEach(() => {
2680
+ vi.mocked(readConfObject).mockImplementation(adapterOnly)
2681
+ mockSession.tracks = []
2682
+ mockSession.assemblyNames = []
2683
+ mockSession.assemblyAliases = {}
2684
+ })
2685
+
2686
+ async function cutFor(
2687
+ assemblyName: string,
2688
+ layoutMode: LayoutModeValue = 'auto',
2689
+ ) {
2690
+ const model = stateModelFactory().create({
2691
+ type: 'GraphGenomeView',
2692
+ layoutMode,
2693
+ loadedTrackId: 'graph',
2694
+ })
2695
+ await model.cutSubgraph(
2696
+ {},
2697
+ { refName: geneAt.refName, assemblyName, start: 1000, end: 1035 },
2698
+ )
2699
+ return model
2700
+ }
2701
+
2702
+ type Model = Awaited<ReturnType<typeof cutFor>>
2703
+ const pinned = (model: Model) => model.genePins.map(p => p.gene.name)
2704
+ const tubeMapped = (model: Model) => model.tubeMapGenes.map(g => g.gene.name)
2705
+
2706
+ async function drawAlong(model: Model, path: string) {
2707
+ model.setReferencePath(path)
2708
+ await model.recomputeLayout()
2709
+ expect(model.activeReferencePath).toBe(path)
2710
+ }
2711
+
2712
+ test.each([
2713
+ ['auto', pinned],
2714
+ ['tubemapref', tubeMapped],
2715
+ ] as const)(
2716
+ '%s draws hg38 genes only while x is drawn along the GRCh38 walk',
2717
+ async (layoutMode, drawn) => {
2718
+ const model = await cutFor('hg38', layoutMode)
2719
+ expect(model.activeReferencePath).toBe('GRCh38#0#chr6')
2720
+ expect(drawn(model)).toEqual(['HLA-A'])
2721
+
2722
+ await drawAlong(model, 'CHM13#0#chr6')
2723
+ expect(model.geneFeatures).toHaveLength(1)
2724
+ expect(drawn(model)).toEqual([])
2725
+
2726
+ await drawAlong(model, 'GRCh38#0#chr6')
2727
+ expect(drawn(model)).toEqual(['HLA-A'])
2728
+ },
2729
+ )
2730
+
2731
+ test('an assembly’s session aliases or PanSN prefix put x on its walk', async () => {
2732
+ gfa = gfaOf(CHM13, GRCH38)
2733
+ geneAt = { refName: 'chr6', ...ON_GRCH38 }
2734
+ sessionWith('mine')
2735
+ const unnamed = await cutFor('mine')
2736
+ expect(unnamed.activeReferencePath).toBe('CHM13#0#chr6')
2737
+ expect(pinned(unnamed)).toEqual([])
2738
+
2739
+ mockSession.assemblyNames = ['mine']
2740
+ mockSession.assemblyAliases = { GRCh38: 'mine' }
2741
+ expect(pinned(await cutFor('mine'))).toEqual(['HLA-A'])
2742
+
2743
+ mockSession.assemblyAliases = {}
2744
+ sessionWith('mine', { assemblyNameToPanSN: { mine: 'GRCh38#0' } })
2745
+ expect(pinned(await cutFor('mine'))).toEqual(['HLA-A'])
2746
+ })
2747
+
2748
+ test('an assemblyNameToPanSN keyed by a session alias puts x on its walk', async () => {
2749
+ gfa = gfaOf(CHM13, GRCH38)
2750
+ geneAt = { refName: 'chr6', ...ON_GRCH38 }
2751
+ mockSession.assemblyNames = ['mine']
2752
+ mockSession.assemblyAliases = { 'GRCh38.p14': 'mine' }
2753
+ sessionWith('mine', { assemblyNameToPanSN: { 'GRCh38.p14': 'GRCh38#0' } })
2754
+ const model = await cutFor('mine')
2755
+ expect(model.activeReferencePath).toBe('GRCh38#0#chr6')
2756
+ expect(pinned(model)).toEqual(['HLA-A'])
2757
+ })
2758
+
2759
+ // gbz-base writes a reference path that names no sample as `_gbwt_ref`
2760
+ test('a vg graph’s generic reference takes its track’s genes', async () => {
2761
+ gfa = gfaOf(
2762
+ 'W\t_gbwt_ref\t0\tchr6\t1000\t1030\t>1>2>4',
2763
+ 'W\tHG002\t1\tchr6\t1005\t1035\t>1>3>4',
2764
+ )
2765
+ const model = await cutFor('hg38')
2766
+ expect(model.activeReferencePath).toBe('_gbwt_ref#0#chr6')
2767
+ expect(pinned(model)).toEqual(['HLA-A'])
2768
+
2769
+ await drawAlong(model, 'HG002#1#chr6')
2770
+ expect(pinned(model)).toEqual([])
2771
+
2772
+ await drawAlong(model, '_gbwt_ref#0#chr6')
2773
+ expect(pinned(model)).toEqual(['HLA-A'])
2774
+ })
2775
+
2776
+ // The adapter cuts along its referenceSample, else along the database's only
2777
+ // reference sample, and names neither after the assembly
2778
+ test.each([
2779
+ [
2780
+ 'the referenceSample slot',
2781
+ 'T2T-CHM13v2.0',
2782
+ { type: 'GbzBaseSyntenyAdapter', referenceSample: 'CHM13' },
2783
+ gfaOf(CHM13, GRCH38),
2784
+ { refName: 'chr6', ...ON_CHM13 },
2785
+ ],
2786
+ [
2787
+ 'the only reference sample',
2788
+ 'mm39',
2789
+ { type: 'GbzBaseSyntenyAdapter' },
2790
+ gfaOf(
2791
+ 'W\tC57BL_6J\t0\tchr17\t1000\t1030\t>1>2>4',
2792
+ 'W\tDBA_2J\t1\tchr17\t1005\t1035\t>1>3>4',
2793
+ ),
2794
+ { refName: 'chr17', ...ON_GRCH38 },
2795
+ ],
2796
+ ])(
2797
+ 'the walk a GBZ track cuts along with %s takes its genes',
2798
+ async (_how, assemblyName, adapter, walks, at) => {
2799
+ gfa = walks
2800
+ geneAt = at
2801
+ sessionWith(assemblyName, adapter)
2802
+ const model = await cutFor(assemblyName)
2803
+ expect(pinned(model)).toEqual(['HLA-A'])
2804
+ },
2805
+ )
2806
+
2807
+ test('a re-cut keeps the genes off a walk the user drew x along', async () => {
2808
+ gfa = gfaOf(CHM13, GRCH38)
2809
+ geneAt = { refName: 'chr6', ...ON_BOTH }
2810
+ sessionWith('T2T-CHM13v2.0', {
2811
+ type: 'GbzBaseSyntenyAdapter',
2812
+ referenceSample: 'CHM13',
2813
+ })
2814
+ const model = await cutFor('T2T-CHM13v2.0')
2815
+ expect(pinned(model)).toEqual(['HLA-A'])
2816
+
2817
+ await drawAlong(model, 'GRCh38#0#chr6')
2818
+ expect(pinned(model)).toEqual([])
2819
+
2820
+ const region = model.graphRegion
2821
+ await model.cutSubgraph(
2822
+ {},
2823
+ {
2824
+ refName: 'chr6',
2825
+ assemblyName: 'T2T-CHM13v2.0',
2826
+ start: 1005,
2827
+ end: 1035,
2828
+ },
2829
+ )
2830
+ expect(model.graphRegion).not.toBe(region)
2831
+ expect(model.activeReferencePath).toBe('GRCh38#0#chr6')
2832
+ expect(pinned(model)).toEqual([])
2833
+
2834
+ await drawAlong(model, 'CHM13#0#chr6')
2835
+ expect(pinned(model)).toEqual(['HLA-A'])
2836
+ })
2837
+
2838
+ test('a diploid’s sample-level name leaves the other haplotype bare', async () => {
2839
+ gfa = gfaOf(
2840
+ 'W\tHG002\t1\tchr6\t1000\t1030\t>1>2>4',
2841
+ 'W\tHG002\t2\tchr6\t1000\t1030\t>1>3>4',
2842
+ )
2843
+ sessionWith('HG002.1', { assemblyNameToPanSN: { 'HG002.1': 'HG002' } })
2844
+ const model = await cutFor('HG002.1')
2845
+ expect(model.activeReferencePath).toBe('HG002#1#chr6')
2846
+ expect(pinned(model)).toEqual(['HLA-A'])
2847
+
2848
+ await drawAlong(model, 'HG002#2#chr6')
2849
+ expect(pinned(model)).toEqual([])
2850
+ })
2851
+
2852
+ test('a bare rGFA takes the assembly its track puts it on', async () => {
2853
+ gfa = BARE_CHR6_RGFA
2854
+ const model = await cutFor('hg38')
2855
+ expect(pinned(model)).toEqual(['HLA-A'])
2856
+
2857
+ model.setReferencePath('CHM13_chr6')
2858
+ await model.recomputeLayout()
2859
+ expect(pinned(model)).toEqual(['HLA-A'])
2860
+ })
2861
+
2862
+ test('a bare path graph takes it only along the path it came with', async () => {
2863
+ gfa = BARE_CHR6_PATHS
2864
+ const model = await cutFor('hg38')
2865
+ expect(model.activeReferencePath).toBe('chr6')
2866
+ expect(pinned(model)).toEqual(['HLA-A'])
2867
+
2868
+ await drawAlong(model, 'CHM13_chr6')
2869
+ expect(model.geneFeatures).toHaveLength(1)
2870
+ expect(model.backboneGenes).toBeUndefined()
2871
+ expect(pinned(model)).toEqual([])
2872
+
2873
+ await drawAlong(model, 'chr6')
2874
+ expect(pinned(model)).toEqual(['HLA-A'])
2875
+ })
2876
+ })