jbrowse-plugin-graphgenomeviewer 7.4.4 → 7.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/chunks/{GbzBaseSyntenyAdapter-A5MNPCKX.js → GbzBaseSyntenyAdapter-3PVPTKNW.js} +2 -2
  2. package/dist/chunks/{GbzBaseSyntenyAdapter-A5MNPCKX.js.map → GbzBaseSyntenyAdapter-3PVPTKNW.js.map} +1 -1
  3. package/dist/chunks/{GraphGenomeView-SEAPDRXS.js → GraphGenomeView-GQW4YC23.js} +2 -2
  4. package/dist/chunks/GraphTrackSettingsDialog-ARWCWJW4.js +4 -0
  5. package/dist/chunks/GraphTrackSettingsDialog-ARWCWJW4.js.map +7 -0
  6. package/dist/chunks/{HighlightColorDialog-QAYWLO5I.js → HighlightColorDialog-PD3KMF2U.js} +2 -2
  7. package/dist/chunks/LinearGraphDisplay-QNS4EAL3.js +2 -0
  8. package/dist/chunks/{LinearGraphDisplay-ZC4BQ27J.js.map → LinearGraphDisplay-QNS4EAL3.js.map} +2 -2
  9. package/dist/chunks/{MinigraphBubbleAdapter-3QM4XV47.js → MinigraphBubbleAdapter-L6JSQIOL.js} +2 -2
  10. package/dist/chunks/RgfaTabixAdapter-5HCAJ7QI.js +2 -0
  11. package/dist/chunks/RgfaTabixAdapter-5HCAJ7QI.js.map +7 -0
  12. package/dist/chunks/{WalkFormsAdapter-FT2SW4H6.js → WalkFormsAdapter-NX36GP35.js} +2 -2
  13. package/dist/chunks/WalkTabixSyntenyAdapter-JFNFX3JP.js +2 -0
  14. package/dist/chunks/{WalkTabixSyntenyAdapter-3WRVNDLB.js.map → WalkTabixSyntenyAdapter-JFNFX3JP.js.map} +2 -2
  15. package/dist/chunks/{chunk-KIYRMZWP.js → chunk-2CPD5NX2.js} +2 -2
  16. package/dist/chunks/{chunk-KIYRMZWP.js.map → chunk-2CPD5NX2.js.map} +1 -1
  17. package/dist/chunks/chunk-3AC33AXB.js +2 -0
  18. package/dist/chunks/{chunk-HF4H6LQI.js → chunk-4PHM6JNU.js} +2 -2
  19. package/dist/chunks/chunk-5LFPNQWH.js +2 -0
  20. package/dist/chunks/{chunk-6VNU7SD4.js.map → chunk-5LFPNQWH.js.map} +4 -4
  21. package/dist/chunks/{chunk-6WUWQX2P.js → chunk-CNEUOM7X.js} +2 -2
  22. package/dist/chunks/{chunk-6WUWQX2P.js.map → chunk-CNEUOM7X.js.map} +1 -1
  23. package/dist/chunks/{chunk-P3FBI5ML.js → chunk-DOC7QIE2.js} +1 -1
  24. package/dist/chunks/{chunk-P3FBI5ML.js.map → chunk-DOC7QIE2.js.map} +1 -1
  25. package/dist/chunks/{chunk-G53BI7VK.js → chunk-G4OPM6G7.js} +2 -2
  26. package/dist/chunks/{chunk-G53BI7VK.js.map → chunk-G4OPM6G7.js.map} +1 -1
  27. package/dist/chunks/chunk-GRLNDNX4.js +2 -0
  28. package/dist/chunks/{chunk-S333HOG5.js.map → chunk-GRLNDNX4.js.map} +4 -4
  29. package/dist/chunks/{chunk-7RJMG6I2.js → chunk-JYYF3U2H.js} +1 -1
  30. package/dist/chunks/{chunk-7RJMG6I2.js.map → chunk-JYYF3U2H.js.map} +1 -1
  31. package/dist/chunks/chunk-KDXVLEHS.js +3 -0
  32. package/dist/chunks/chunk-KDXVLEHS.js.map +7 -0
  33. package/dist/chunks/chunk-LGV6BTOY.js +2 -0
  34. package/dist/chunks/{chunk-HCDKP3IP.js.map → chunk-LGV6BTOY.js.map} +4 -4
  35. package/dist/chunks/{chunk-2KVE7YAS.js → chunk-LJ7O3736.js} +1 -1
  36. package/dist/chunks/{chunk-2KVE7YAS.js.map → chunk-LJ7O3736.js.map} +1 -1
  37. package/dist/chunks/{chunk-OJWBLPPH.js → chunk-OQHN2GWX.js} +2 -2
  38. package/dist/chunks/chunk-PTDOYJFH.js +4 -0
  39. package/dist/chunks/chunk-PTDOYJFH.js.map +7 -0
  40. package/dist/chunks/chunk-SGP63UMC.js +2 -0
  41. package/dist/chunks/chunk-SGP63UMC.js.map +7 -0
  42. package/dist/chunks/{chunk-XXBE4IFZ.js → chunk-SI5VLKVN.js} +2 -2
  43. package/dist/chunks/{chunk-XXBE4IFZ.js.map → chunk-SI5VLKVN.js.map} +1 -1
  44. package/dist/chunks/{chunk-6SWXMRAV.js → chunk-TWPUVVYH.js} +1 -1
  45. package/dist/chunks/{chunk-6SWXMRAV.js.map → chunk-TWPUVVYH.js.map} +1 -1
  46. package/dist/chunks/{chunk-FTXDTU27.js → chunk-XF6GU4GK.js} +3 -3
  47. package/dist/chunks/{chunk-FTXDTU27.js.map → chunk-XF6GU4GK.js.map} +2 -2
  48. package/dist/chunks/{chunk-MUONTMYZ.js → chunk-XSDGE4RF.js} +2 -2
  49. package/dist/chunks/{chunk-LE7FZM6Y.js → chunk-ZFJGNGIB.js} +2 -2
  50. package/dist/chunks/{chunk-LE7FZM6Y.js.map → chunk-ZFJGNGIB.js.map} +1 -1
  51. package/dist/chunks/chunk-ZI7RFIA2.js +4 -0
  52. package/dist/chunks/chunk-ZI7RFIA2.js.map +7 -0
  53. package/dist/chunks/{renderSvg-ZIUOPWJQ.js → renderSvg-NIWFCY3U.js} +2 -2
  54. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +4 -3
  55. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +4 -4
  56. package/package.json +2 -2
  57. package/src/GraphComputeLayout.test.ts +24 -0
  58. package/src/GraphComputeLayout.ts +4 -2
  59. package/src/GraphGenomeView/components/GraphSettingsDialog.tsx +16 -0
  60. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +83 -0
  61. package/src/GraphGenomeView/components/usePaneGestures.test.tsx +82 -5
  62. package/src/GraphGenomeView/components/usePaneGestures.ts +10 -4
  63. package/src/GraphGenomeView/graphLayers.ts +14 -1
  64. package/src/GraphGenomeView/legendSections.ts +32 -0
  65. package/src/GraphGenomeView/model.test.ts +307 -7
  66. package/src/GraphGenomeView/model.ts +3 -10
  67. package/src/GraphGenomeView/nodeColor.test.ts +27 -1
  68. package/src/GraphGenomeView/nodeColor.ts +38 -29
  69. package/src/GraphGenomeView/nodeSize.test.ts +21 -5
  70. package/src/GraphGenomeView/nodeSize.ts +24 -8
  71. package/src/GraphGenomeView/pane/fitViews.ts +5 -1
  72. package/src/GraphGenomeView/pane/graphViews.ts +102 -20
  73. package/src/GraphGenomeView/pane/launchActions.ts +55 -11
  74. package/src/GraphGenomeView/pane/launchViews.ts +46 -0
  75. package/src/GraphGenomeView/pane/loadActions.ts +85 -25
  76. package/src/GraphGenomeView/pane/morph.test.ts +25 -1
  77. package/src/GraphGenomeView/pane/morph.ts +3 -1
  78. package/src/GraphGenomeView/pane/paneBase.ts +5 -0
  79. package/src/GraphGenomeView/pane/renderingActions.ts +61 -2
  80. package/src/GraphGenomeView/pane/settingActions.ts +65 -5
  81. package/src/GraphGenomeView/pane/walkRowViews.ts +17 -6
  82. package/src/GraphGenomeView/retiredNodeColor.test.ts +46 -0
  83. package/src/GraphGenomeView/retiredNodeColor.ts +76 -0
  84. package/src/GraphGenomeView/tubeMapPanels.ts +2 -1
  85. package/src/GraphGenomeView/viewModel.ts +16 -9
  86. package/src/GraphGenomeView/walkRowBlocks.ts +50 -0
  87. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +97 -10
  88. package/src/LinearGraphDisplay/components/GraphStatusChrome.test.tsx +18 -0
  89. package/src/LinearGraphDisplay/components/GraphStatusChrome.tsx +4 -2
  90. package/src/LinearGraphDisplay/components/SubgraphHaplotypesField.test.tsx +41 -1
  91. package/src/LinearGraphDisplay/components/SubgraphHaplotypesField.tsx +33 -19
  92. package/src/LinearGraphDisplay/configSchema.ts +18 -10
  93. package/src/LinearGraphDisplay/model.ts +4 -2
  94. package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +2 -9
  95. package/src/WalkTabixSyntenyAdapter/WalkTabixSyntenyAdapter.ts +3 -6
  96. package/src/hoverSync/graphViewHighlights.test.ts +53 -0
  97. package/src/hoverSync/graphViewHighlights.ts +25 -0
  98. package/src/hoverSync/lgvHover.test.ts +100 -1
  99. package/src/hoverSync/lgvHover.ts +77 -6
  100. package/src/launchFromGraph/contributors.test.ts +52 -0
  101. package/src/launchFromGraph/contributors.ts +105 -1
  102. package/src/launchFromGraph/launchFromGraph.ts +2 -2
  103. package/src/panSNTabix.test.ts +28 -0
  104. package/src/panSNTabix.ts +26 -8
  105. package/src/version.ts +1 -1
  106. package/dist/chunks/GraphTrackSettingsDialog-EVIIGC5G.js +0 -6
  107. package/dist/chunks/GraphTrackSettingsDialog-EVIIGC5G.js.map +0 -7
  108. package/dist/chunks/LinearGraphDisplay-ZC4BQ27J.js +0 -2
  109. package/dist/chunks/RgfaTabixAdapter-XSOIEWBK.js +0 -2
  110. package/dist/chunks/RgfaTabixAdapter-XSOIEWBK.js.map +0 -7
  111. package/dist/chunks/WalkTabixSyntenyAdapter-3WRVNDLB.js +0 -2
  112. package/dist/chunks/chunk-6VNU7SD4.js +0 -2
  113. package/dist/chunks/chunk-GRTN25B6.js +0 -3
  114. package/dist/chunks/chunk-GRTN25B6.js.map +0 -7
  115. package/dist/chunks/chunk-HCDKP3IP.js +0 -2
  116. package/dist/chunks/chunk-LE43I4PT.js +0 -4
  117. package/dist/chunks/chunk-LE43I4PT.js.map +0 -7
  118. package/dist/chunks/chunk-QV7SSOMJ.js +0 -2
  119. package/dist/chunks/chunk-S333HOG5.js +0 -2
  120. package/dist/chunks/chunk-VZQ6VOUP.js +0 -2
  121. package/dist/chunks/chunk-VZQ6VOUP.js.map +0 -7
  122. package/dist/chunks/chunk-WLRRI35A.js +0 -3
  123. package/dist/chunks/chunk-WLRRI35A.js.map +0 -7
  124. package/src/launchFromGraph/launchTracks.test.ts +0 -95
  125. package/src/launchFromGraph/launchTracks.ts +0 -49
  126. /package/dist/chunks/{GraphGenomeView-SEAPDRXS.js.map → GraphGenomeView-GQW4YC23.js.map} +0 -0
  127. /package/dist/chunks/{HighlightColorDialog-QAYWLO5I.js.map → HighlightColorDialog-PD3KMF2U.js.map} +0 -0
  128. /package/dist/chunks/{MinigraphBubbleAdapter-3QM4XV47.js.map → MinigraphBubbleAdapter-L6JSQIOL.js.map} +0 -0
  129. /package/dist/chunks/{WalkFormsAdapter-FT2SW4H6.js.map → WalkFormsAdapter-NX36GP35.js.map} +0 -0
  130. /package/dist/chunks/{chunk-QV7SSOMJ.js.map → chunk-3AC33AXB.js.map} +0 -0
  131. /package/dist/chunks/{chunk-HF4H6LQI.js.map → chunk-4PHM6JNU.js.map} +0 -0
  132. /package/dist/chunks/{chunk-OJWBLPPH.js.map → chunk-OQHN2GWX.js.map} +0 -0
  133. /package/dist/chunks/{chunk-MUONTMYZ.js.map → chunk-XSDGE4RF.js.map} +0 -0
  134. /package/dist/chunks/{renderSvg-ZIUOPWJQ.js.map → renderSvg-NIWFCY3U.js.map} +0 -0
@@ -156,6 +156,7 @@ vi.mock('@jbrowse/core/util/io', () => ({
156
156
  }))
157
157
 
158
158
  const SIMPLE_GFA = 'H\tVN:Z:1.0\nS\t1\tACGT\nS\t2\tGGCC\nL\t1\t+\t2\t+\t0M\n'
159
+ const WALKED_GFA = `${SIMPLE_GFA}W\tGRCh38\t0\tchr1\t0\t8\t>1>2\nW\tA\t1\tctg\t0\t8\t>1>2\n`
159
160
 
160
161
  const MOCK_LAYOUT = {
161
162
  nodePositions: {
@@ -218,7 +219,9 @@ function createAnchoredModel(props: { layers?: GraphLayers } = {}) {
218
219
 
219
220
  // A view attached to a tree, so afterAttach installs its reactions. A root
220
221
  // model never attaches.
221
- function attachedModel(props: { layoutMode?: LayoutModeValue } = {}) {
222
+ function attachedModel(
223
+ props: { layoutMode?: LayoutModeValue; [key: string]: unknown } = {},
224
+ ) {
222
225
  return types
223
226
  .model({ view: stateModelFactory() })
224
227
  .create({ view: { type: 'GraphGenomeView', ...props } }).view
@@ -292,12 +295,24 @@ describe('rampDomain', () => {
292
295
  expect(model.rampDomain).toBeUndefined()
293
296
  })
294
297
 
298
+ test('a saved flat color is read as the channel object', () => {
299
+ const model = restoredView({
300
+ color: { field: 'position', domainMin: 5, domainMax: 50 },
301
+ })
302
+ expect(model.color).toEqual({
303
+ field: 'position',
304
+ scale: { domainMin: 5, domainMax: 50 },
305
+ })
306
+ expect(model.statedColorDomain).toEqual({ start: 5, end: 50 })
307
+ expect(restoredView({ color: 'uniform' }).chosenColorScheme).toBe('uniform')
308
+ })
309
+
295
310
  test('a stated color domain survives loading a GFA file, and wins', async () => {
296
311
  rpcRespond()
297
312
  const model = createModel()
298
313
  applySnapshot(model, {
299
314
  ...getSnapshot(model),
300
- color: { domainMin: 1445000, domainMax: 1474500 },
315
+ color: { scale: { domainMin: 1445000, domainMax: 1474500 } },
301
316
  loadedRegion: TEST_REGION,
302
317
  })
303
318
  await model.loadGFA(SIMPLE_GFA)
@@ -366,6 +381,30 @@ describe('restoring a stated cut', () => {
366
381
  )
367
382
  })
368
383
 
384
+ // Only a GBZ cut follows snarls past the window; a walk file cuts the same
385
+ // graph either way, so the strip's toggle has nothing to fetch.
386
+ test.each([
387
+ ['RgfaTabixAdapter', 0],
388
+ ['GbzBaseSyntenyAdapter', 1],
389
+ ])('the walk strip on a %s track cuts again %i times', async (type, n) => {
390
+ rpcRespond()
391
+ mockSession.tracks = [{ trackId: 'rgfa-track', adapter: { type } }]
392
+ const model = attachedModel({
393
+ layoutMode: 'force',
394
+ loadedTrackId: 'rgfa-track',
395
+ loadedRegion: TEST_REGION,
396
+ })
397
+ await vi.waitFor(() => {
398
+ expect(model.graph).toBeDefined()
399
+ })
400
+ const cuts = () =>
401
+ mockRpcCall.mock.calls.filter(c => c[1] === 'GetSubgraph').length
402
+ const before = cuts()
403
+ model.setWalkStrip(!model.walkStrip)
404
+ await layoutsSettled(model)
405
+ expect(cuts()).toBe(before + n)
406
+ })
407
+
369
408
  test('a stated track the session lacks is reported without a fetch', async () => {
370
409
  mockSession.tracks = []
371
410
  const model = restoredView()
@@ -1136,7 +1175,7 @@ describe('layoutMode', () => {
1136
1175
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1137
1176
  rpcRespond()
1138
1177
  const model = attachedModel()
1139
- await model.loadGFA(SIMPLE_GFA, 'plain')
1178
+ await model.loadGFA(WALKED_GFA, 'plain')
1140
1179
 
1141
1180
  model.setMaxGraphNodes(1)
1142
1181
  await layoutsSettled(model)
@@ -1434,7 +1473,7 @@ describe('node budget', () => {
1434
1473
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1435
1474
  rpcRespond()
1436
1475
  const model = withLimit(1)
1437
- await model.loadGFA(SIMPLE_GFA, 'two nodes')
1476
+ await model.loadGFA(WALKED_GFA, 'two nodes')
1438
1477
  expect(String(model.error)).toMatch(/too large to draw/i)
1439
1478
 
1440
1479
  await model.switchLayout('walkrows')
@@ -1443,6 +1482,21 @@ describe('node budget', () => {
1443
1482
  consoleSpy.mockRestore()
1444
1483
  })
1445
1484
 
1485
+ // walk rows hand a graph with no walks to the force engine, which draws
1486
+ // every node
1487
+ test('walk rows of a graph with no walks keep the budget', async () => {
1488
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1489
+ rpcRespond()
1490
+ const model = withLimit(1)
1491
+ model.setLayoutMode('walkrows')
1492
+ await model.loadGFA(SIMPLE_GFA, 'two nodes')
1493
+ expect(String(model.error)).toMatch(/too large to draw/i)
1494
+ expect(
1495
+ mockRpcCall.mock.calls.filter(c => c[1] === 'GraphComputeLayout'),
1496
+ ).toHaveLength(0)
1497
+ consoleSpy.mockRestore()
1498
+ })
1499
+
1446
1500
  test('a declined graph does not keep the last graph drawn under it', async () => {
1447
1501
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1448
1502
  rpcRespond()
@@ -1466,7 +1520,7 @@ describe('node budget', () => {
1466
1520
  rpcRespond()
1467
1521
  const model = withLimit(1)
1468
1522
  model.setLayoutMode('walkrows')
1469
- await model.loadGFA(SIMPLE_GFA, 'two nodes')
1523
+ await model.loadGFA(WALKED_GFA, 'two nodes')
1470
1524
  expect(model.error).toBeUndefined()
1471
1525
  expect(model.layoutResult).toBeDefined()
1472
1526
  })
@@ -2279,6 +2333,61 @@ describe('launching out of the graph', () => {
2279
2333
  // Where every contributor is a loaded assembly the graph offers one panel per
2280
2334
  // strain, and a synteny view of all of them — the item is disabled while no
2281
2335
  // synteny track aligns them, rather than absent.
2336
+ // Sakai's allele is drawn bare unless Sakai's own annotation is read onto
2337
+ // it: the reference's genes only reach the backbone
2338
+ test("a loaded strain's own genes are read onto its nodes off the reference", async () => {
2339
+ mockSession.assemblyNames = ['K12', 'Sakai']
2340
+ mockSession.tracks = [
2341
+ {
2342
+ trackId: 'Sakai_genes',
2343
+ name: 'Sakai genes',
2344
+ assemblyNames: ['Sakai'],
2345
+ adapter: { type: 'Gff3TabixAdapter' },
2346
+ },
2347
+ ]
2348
+ const model = await loadedGraph(ECOLI_RGFA, {
2349
+ refName: 'chr',
2350
+ assemblyName: 'K12',
2351
+ start: 1000,
2352
+ end: 1020,
2353
+ })
2354
+ expect(model.ownGeneReads).toEqual([
2355
+ {
2356
+ stableRefName: 'Sakai#1#chr',
2357
+ trackId: 'Sakai_genes',
2358
+ region: {
2359
+ assemblyName: 'Sakai',
2360
+ refName: 'chr',
2361
+ start: 90000,
2362
+ end: 90004,
2363
+ },
2364
+ },
2365
+ ])
2366
+ mockRpcCall.mockImplementation((_sid: unknown, method: string) =>
2367
+ method === 'CoreGetFeatures'
2368
+ ? Promise.resolve([
2369
+ {
2370
+ name: 'stx2A',
2371
+ refName: 'chr',
2372
+ start: 90001,
2373
+ end: 90003,
2374
+ strand: 1,
2375
+ subfeatures: [{ type: 'exon', start: 90001, end: 90003 }],
2376
+ },
2377
+ ])
2378
+ : Promise.reject(new Error(`Unexpected RPC: ${method}`)),
2379
+ )
2380
+ await model.loadOwnGenes()
2381
+ expect(model.ownGeneFeatures).toEqual([
2382
+ expect.objectContaining({
2383
+ name: 'stx2A · Sakai#1',
2384
+ refName: 'Sakai#1#chr',
2385
+ start: 90001,
2386
+ end: 90003,
2387
+ }),
2388
+ ])
2389
+ })
2390
+
2282
2391
  test('loaded strains offer a per-strain linear view and a synteny item', async () => {
2283
2392
  mockSession.assemblyNames = ['K12', 'Sakai']
2284
2393
  const model = await loadedGraph(ECOLI_RGFA, {
@@ -2345,6 +2454,68 @@ describe('launching out of the graph', () => {
2345
2454
  expect(model.connectedViewId).toBe('view-1')
2346
2455
  })
2347
2456
 
2457
+ test('an opened view carries one gene track, not every feature track', async () => {
2458
+ mockSession.assemblyNames = ['hg38']
2459
+ const feature = (trackId: string, name: string, type: string) => ({
2460
+ trackId,
2461
+ name,
2462
+ type: 'FeatureTrack',
2463
+ assemblyNames: ['hg38'],
2464
+ adapter: { type },
2465
+ })
2466
+ mockSession.tracks = [
2467
+ feature('rgfa-track', 'graph', 'RgfaTabixAdapter'),
2468
+ feature('clinvar', 'ClinVar', 'BigBedAdapter'),
2469
+ feature('ncbi', 'NCBI RefSeq genes', 'Gff3TabixAdapter'),
2470
+ feature('gwas', 'GWAS catalog', 'BedTabixAdapter'),
2471
+ ]
2472
+ const model = await loadedGraph(HPRC_RGFA)
2473
+ const { reference } = model.nodeLaunchTargets('1+')
2474
+ model.showInLinearView(reference!)
2475
+
2476
+ expect(mockSession.addedViews).toEqual([
2477
+ [
2478
+ 'LinearGenomeView',
2479
+ expect.objectContaining({
2480
+ init: expect.objectContaining({ tracks: ['rgfa-track', 'ncbi'] }),
2481
+ }),
2482
+ ],
2483
+ ])
2484
+ })
2485
+
2486
+ // a BED named for no annotation is not picked as genes, but beats `No
2487
+ // tracks active`
2488
+ test('a strain with no gene-looking track opens on its first feature track', async () => {
2489
+ mockSession.assemblyNames = ['K12', 'Sakai']
2490
+ mockSession.tracks = [
2491
+ {
2492
+ trackId: 'Sakai_segments',
2493
+ name: 'Sakai segments',
2494
+ type: 'FeatureTrack',
2495
+ assemblyNames: ['Sakai'],
2496
+ adapter: { type: 'BedAdapter' },
2497
+ },
2498
+ ]
2499
+ const model = await loadedGraph(ECOLI_RGFA, {
2500
+ refName: 'chr',
2501
+ assemblyName: 'K12',
2502
+ start: 1000,
2503
+ end: 1020,
2504
+ })
2505
+ model.showInLinearView(model.nodeLaunchTargets('3+').own!)
2506
+ expect(mockSession.addedViews).toEqual([
2507
+ [
2508
+ 'LinearGenomeView',
2509
+ expect.objectContaining({
2510
+ init: expect.objectContaining({
2511
+ assembly: 'Sakai',
2512
+ tracks: ['Sakai_segments'],
2513
+ }),
2514
+ }),
2515
+ ],
2516
+ ])
2517
+ })
2518
+
2348
2519
  // A pairing is not stolen from a view still on screen: a graph launched from
2349
2520
  // a linear view came in on that sync. One whose view has been closed is held
2350
2521
  // by nothing, and kept, the view the graph opens next never got its hover.
@@ -2978,7 +3149,7 @@ describe('the auto color scheme', () => {
2978
3149
  const model = createModel()
2979
3150
  applySnapshot(model, {
2980
3151
  ...getSnapshot(model),
2981
- color: { domainMin: 1000, domainMax: 5000 },
3152
+ color: { scale: { domainMin: 1000, domainMax: 5000 } },
2982
3153
  })
2983
3154
  await model.loadGFA(RGFA, 'rgfa')
2984
3155
 
@@ -3244,6 +3415,71 @@ describe('walk rows', () => {
3244
3415
  expect(model.walkStripRows).toBeUndefined()
3245
3416
  })
3246
3417
 
3418
+ // node 2 is off GRCh38's path and both A#1 and B#1 walk it
3419
+ test('the block two rows walk off the reference is lettered on both', async () => {
3420
+ rpcRespond()
3421
+ const model = stateModelFactory().create({
3422
+ type: 'GraphGenomeView',
3423
+ layoutMode: 'walkrows',
3424
+ })
3425
+ await model.loadGFA(WALKS_GFA, 'walks')
3426
+ expect(model.sharedBlocks).toBeUndefined()
3427
+ model.setLayer('walkBlocks', true)
3428
+ const rows = [model.walkRowBars!.reference, ...model.walkRowBars!.rows]
3429
+ const lettered = model.sharedBlocks!.map(b => ({
3430
+ label: rows[b.row]!.label,
3431
+ start: b.start,
3432
+ bp: b.bp,
3433
+ block: b.block,
3434
+ }))
3435
+ expect(lettered.sort((a, b) => a.label.localeCompare(b.label))).toEqual([
3436
+ { label: 'A#1', start: 4, bp: 8, block: 0 },
3437
+ { label: 'B#1', start: 4, bp: 8, block: 0 },
3438
+ ])
3439
+ })
3440
+
3441
+ test('the shared order stacks rows that walk the same nodes together', async () => {
3442
+ rpcRespond()
3443
+ const model = stateModelFactory().create({
3444
+ type: 'GraphGenomeView',
3445
+ layoutMode: 'walkrows',
3446
+ })
3447
+ await model.loadGFA(WALKS_GFA, 'walks')
3448
+ model.setWalkRowSamples(['A', 'B'])
3449
+ const before = model.walkRowBars!.rows.map(r => r.label)
3450
+ model.setWalkRowOrder('shared')
3451
+ const labels = model.walkRowBars!.rows.map(r => r.label)
3452
+ expect(labels).not.toEqual(before)
3453
+ // A#1 and B#1 share node 2, so B#1 stacks under A#1 rather than after
3454
+ // A#2, which walks GRCh38's own route
3455
+ expect(labels).toEqual(['A#1', 'B#1', 'A#2'])
3456
+ // the sample filter and the order sit side by side in the grammar
3457
+ expect(model.walkRowSamples).toEqual(['A', 'B'])
3458
+ model.setWalkRowSamples(undefined)
3459
+ expect(model.walkRowOrder).toBe('shared')
3460
+ })
3461
+
3462
+ test('ribbons join neighbouring walk rows where they walk the same nodes', async () => {
3463
+ rpcRespond()
3464
+ const model = stateModelFactory().create({
3465
+ type: 'GraphGenomeView',
3466
+ layoutMode: 'walkrows',
3467
+ })
3468
+ await model.loadGFA(WALKS_GFA, 'walks')
3469
+ expect(model.walkRowRibbons).toBeUndefined()
3470
+ model.setLayer('walkRibbons', true)
3471
+ const ribbons = model.walkRowRibbons!
3472
+ expect(ribbons.length).toBeGreaterThan(0)
3473
+ // every row passes nodes 1 and 3, so each pair of neighbours shares a
3474
+ // stretch that starts at both bars' left ends
3475
+ const rows = [model.walkRowBars!.reference, ...model.walkRowBars!.rows]
3476
+ for (let row = 0; row < rows.length - 1; row++) {
3477
+ expect(ribbons.some(r => r.row === row && r.a0 === 0 && r.b0 === 0)).toBe(
3478
+ true,
3479
+ )
3480
+ }
3481
+ })
3482
+
3247
3483
  test('walk rows and tube maps take no strip', async () => {
3248
3484
  rpcRespond()
3249
3485
  const model = stateModelFactory().create({
@@ -3604,7 +3840,7 @@ describe('walk rows', () => {
3604
3840
  model.liftWalks(['B#1#ctg'])
3605
3841
  const svg = model.figure()!
3606
3842
  expect(svg).toMatch(/^<svg xmlns="http:\/\/www\.w3\.org\/2000\/svg"/)
3607
- expect(svg).toContain('not on B#1')
3843
+ expect(svg).toContain('only in other haplotypes')
3608
3844
  })
3609
3845
 
3610
3846
  test('a tube map lifts no walk: its tubes are the walks', async () => {
@@ -3801,6 +4037,33 @@ describe('walk rows', () => {
3801
4037
  model.setHoveredRowWalks([])
3802
4038
  expect(model.walkLift).toBeUndefined()
3803
4039
  })
4040
+
4041
+ // each lift rebuilds the drawing, and a sweep down a lane crosses many rows
4042
+ test('moving between lane rows lifts the next once the pointer settles', async () => {
4043
+ rpcRespond()
4044
+ const model = stateModelFactory().create({
4045
+ type: 'GraphGenomeView',
4046
+ layoutMode: 'auto',
4047
+ })
4048
+ await model.loadGFA(WALKS_GFA, 'walks')
4049
+ vi.useFakeTimers()
4050
+ try {
4051
+ model.setHoveredRowWalks(['A#1#ctg'])
4052
+ expect(model.hoveredRowWalks).toEqual(['A#1#ctg'])
4053
+ model.setHoveredRowWalks(['A#2#ctg'])
4054
+ model.setHoveredRowWalks(['B#1#ctg'])
4055
+ vi.advanceTimersByTime(40)
4056
+ // more moves within the row do not put the lift off
4057
+ model.setHoveredRowWalks(['B#1#ctg'])
4058
+ expect(model.hoveredRowWalks).toEqual(['A#1#ctg'])
4059
+ vi.advanceTimersByTime(60)
4060
+ expect(model.hoveredRowWalks).toEqual(['B#1#ctg'])
4061
+ model.setHoveredRowWalks([])
4062
+ expect(model.hoveredRowWalks).toEqual([])
4063
+ } finally {
4064
+ vi.useRealTimers()
4065
+ }
4066
+ })
3804
4067
  })
3805
4068
 
3806
4069
  describe('annotation reads beside a cut', () => {
@@ -3900,6 +4163,43 @@ describe('annotation reads beside a cut', () => {
3900
4163
  expect(model.error).toBeUndefined()
3901
4164
  })
3902
4165
 
4166
+ // most graphs have no bubble index, and a pan re-cuts many times
4167
+ test('a bubble index that failed to read is not asked for again', async () => {
4168
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
4169
+ const bubbleReads = () =>
4170
+ mockRpcCall.mock.calls.filter(
4171
+ c =>
4172
+ (c[2] as { adapterConfig?: { type?: string } }).adapterConfig
4173
+ ?.type === 'MinigraphBubbleAdapter',
4174
+ ).length
4175
+ const respond = mockRpcCall.getMockImplementation()!
4176
+ mockRpcCall.mockImplementation(
4177
+ (sid: unknown, method: string, args: Record<string, unknown>) =>
4178
+ (args.adapterConfig as { type?: string } | undefined)?.type ===
4179
+ 'MinigraphBubbleAdapter'
4180
+ ? Promise.reject(new Error('404'))
4181
+ : respond(sid, method, args),
4182
+ )
4183
+ const model = attachedModel()
4184
+ const settleAll = async () => {
4185
+ await vi.waitFor(() => {
4186
+ expect(reads.length).toBeGreaterThanOrEqual(2)
4187
+ })
4188
+ for (const read of reads.splice(0)) {
4189
+ read.settle([])
4190
+ }
4191
+ }
4192
+ let loaded = cut(model)
4193
+ await settleAll()
4194
+ await loaded
4195
+ expect(bubbleReads()).toBe(1)
4196
+ loaded = cut(model)
4197
+ await settleAll()
4198
+ await loaded
4199
+ expect(bubbleReads()).toBe(1)
4200
+ warn.mockRestore()
4201
+ })
4202
+
3903
4203
  test('a cut too large to draw still reads its genes, for when the limit is raised', async () => {
3904
4204
  vi.spyOn(console, 'error').mockImplementation(() => {})
3905
4205
  const model = attachedModel()
@@ -1,5 +1,3 @@
1
- import { cutsWholeWalks as wholeWalksRule } from '@jbrowse/bandage-core/layout/walkStrip'
2
- import { layoutModeByValue } from '@jbrowse/bandage-core/layoutModes'
3
1
  import { getSession } from '@jbrowse/core/util'
4
2
 
5
3
  import {
@@ -46,16 +44,11 @@ export const GraphPane = withLaunchActions
46
44
  canonicalAssemblyName(session, region.assemblyName),
47
45
  )
48
46
  },
49
- // Walk rows, and the strip of them under a node layout, measure whole
50
- // walks, which a GBZ cut only follows when asked, so a layout change
51
- // that changes whether the drawing needs them cuts the track again.
47
+ // A layout change that changes the cut options cuts the track again:
48
+ // whole walks (cutsWholeWalksIn), or the walk-row step budget.
52
49
  cutChangedFrom(from: LayoutModeValue) {
53
- const before = wholeWalksRule(layoutModeByValue(from), {
54
- walkStrip: self.walkStrip,
55
- host: !!self.host,
56
- })
57
50
  return (
58
- self.cutsWholeWalks !== before ||
51
+ self.cutsWholeWalks !== self.cutsWholeWalksIn(from) ||
59
52
  (from === 'walkrows') !== (self.chosenLayoutMode === 'walkrows')
60
53
  )
61
54
  },
@@ -8,12 +8,38 @@ test.each(COLOR_SCHEME_VALUES)('%s survives the trip through a color', s => {
8
8
 
9
9
  test('a color carries its domain', () => {
10
10
  const color = colorOfScheme('reference-position', { start: 5, end: 50 })
11
- expect(color).toEqual({ field: 'position', domainMin: 5, domainMax: 50 })
11
+ expect(color).toEqual({
12
+ field: 'position',
13
+ scale: { domainMin: 5, domainMax: 50 },
14
+ })
12
15
  expect(domainOfColor(color)).toEqual({ start: 5, end: 50 })
13
16
  })
14
17
 
15
18
  test('a color it cannot read is auto', () => {
16
19
  expect(schemeOfColor({ field: 'gc' })).toBe('auto')
17
20
  expect(schemeOfColor('red')).toBe('auto')
21
+ expect(schemeOfColor({ value: 'red' })).toBe('auto')
18
22
  expect(schemeOfColor(undefined)).toBe('auto')
19
23
  })
24
+
25
+ test('a constant is a value and a field is its own member', () => {
26
+ expect(colorOfScheme('grey')).toEqual({ value: 'grey' })
27
+ expect(colorOfScheme('depth')).toEqual({ field: 'depth' })
28
+ })
29
+
30
+ test('the id field reads its scale off the nested scale', () => {
31
+ expect(schemeOfColor({ field: 'id' })).toBe('random')
32
+ expect(schemeOfColor({ field: 'id', scale: { type: 'ordinal' } })).toBe(
33
+ 'random',
34
+ )
35
+ expect(schemeOfColor({ field: 'id', scale: { scheme: 'rainbow' } })).toBe(
36
+ 'rainbow',
37
+ )
38
+ })
39
+
40
+ test('a flat member beside the field reads as nothing', () => {
41
+ expect(schemeOfColor({ field: 'id', scheme: 'rainbow' })).toBe('random')
42
+ expect(domainOfColor({ field: 'position', domainMin: 1, domainMax: 2 })).toBe(
43
+ undefined,
44
+ )
45
+ })
@@ -7,21 +7,28 @@ interface Span {
7
7
  end: number
8
8
  }
9
9
 
10
- // The node color as a config or session writes it, in core's color-object
11
- // members (ADR-131, ADR-151): a named constant, or the field the nodes are
12
- // colored by. `{}` or unset picks by the graph, reference position on an
13
- // anchored one. Translated here because Bandage's painter takes its own
14
- // schemes, which BandageJS shares.
15
- export type NodeColor =
16
- | 'grey'
17
- | 'uniform'
18
- | {
19
- field?: 'depth' | 'length' | 'rank' | 'position' | 'id'
20
- scale?: 'categorical'
21
- scheme?: 'rainbow'
22
- domainMin?: number
23
- domainMax?: number
24
- }
10
+ // The node color as a config or session writes it, in core's channel object
11
+ // (ADR-240): a `value` naming a constant scheme, or the `field` the nodes are
12
+ // colored by with the `scale` it maps through. `{}` or unset picks by the
13
+ // graph, reference position on an anchored one. Not display-kit's
14
+ // `colorConfigSchema`: its `value` is a CSS color and its `field` a feature
15
+ // attribute, where Bandage's painter takes its own schemes, which BandageJS
16
+ // shares.
17
+ export interface NodeColor {
18
+ value?: 'grey' | 'uniform'
19
+ field?: 'depth' | 'length' | 'rank' | 'position' | 'id'
20
+ scale?: {
21
+ type?: 'ordinal'
22
+ scheme?: 'rainbow'
23
+ domainMin?: number
24
+ domainMax?: number
25
+ }
26
+ }
27
+
28
+ // What the node color sits in on a display's config: core's `encoding`
29
+ export interface GraphEncoding {
30
+ color?: NodeColor
31
+ }
25
32
 
26
33
  const SCHEME_OF_FIELD = {
27
34
  depth: 'depth',
@@ -35,13 +42,15 @@ const FIELD_OF_SCHEME = Object.fromEntries(
35
42
  ) as Record<string, keyof typeof SCHEME_OF_FIELD>
36
43
 
37
44
  export function schemeOfColor(color: unknown): ColorScheme {
38
- if (color === 'grey' || color === 'uniform') {
39
- return color
40
- }
41
45
  if (isRecord(color)) {
42
- const { field, scheme } = color
46
+ const { field, value, scale } = color
47
+ if (value === 'grey' || value === 'uniform') {
48
+ return value
49
+ }
43
50
  if (field === 'id') {
44
- return scheme === 'rainbow' ? 'rainbow' : 'random'
51
+ return isRecord(scale) && scale.scheme === 'rainbow'
52
+ ? 'rainbow'
53
+ : 'random'
45
54
  }
46
55
  if (typeof field === 'string' && field in SCHEME_OF_FIELD) {
47
56
  return SCHEME_OF_FIELD[field as keyof typeof SCHEME_OF_FIELD]
@@ -51,26 +60,26 @@ export function schemeOfColor(color: unknown): ColorScheme {
51
60
  }
52
61
 
53
62
  export function domainOfColor(color: unknown): Span | undefined {
54
- return isRecord(color) &&
55
- typeof color.domainMin === 'number' &&
56
- typeof color.domainMax === 'number'
57
- ? { start: color.domainMin, end: color.domainMax }
63
+ const scale = isRecord(color) ? color.scale : undefined
64
+ return isRecord(scale) &&
65
+ typeof scale.domainMin === 'number' &&
66
+ typeof scale.domainMax === 'number'
67
+ ? { start: scale.domainMin, end: scale.domainMax }
58
68
  : undefined
59
69
  }
60
70
 
61
71
  export function colorOfScheme(scheme: ColorScheme, domain?: Span): NodeColor {
62
72
  if (scheme === 'grey' || scheme === 'uniform') {
63
- return scheme
73
+ return { value: scheme }
64
74
  }
65
75
  return scheme === 'random'
66
- ? { field: 'id', scale: 'categorical' }
76
+ ? { field: 'id', scale: { type: 'ordinal' } }
67
77
  : scheme === 'rainbow'
68
- ? { field: 'id', scheme: 'rainbow' }
78
+ ? { field: 'id', scale: { scheme: 'rainbow' } }
69
79
  : scheme === 'reference-position' && domain
70
80
  ? {
71
81
  field: 'position',
72
- domainMin: domain.start,
73
- domainMax: domain.end,
82
+ scale: { domainMin: domain.start, domainMax: domain.end },
74
83
  }
75
84
  : FIELD_OF_SCHEME[scheme]
76
85
  ? { field: FIELD_OF_SCHEME[scheme] }
@@ -1,14 +1,30 @@
1
- import { nodeSizeOf, sizeOfNodeWidth } from './nodeSize'
1
+ import { nodeSizeOf, sizeOf } from './nodeSize'
2
2
 
3
3
  test('a number is a uniform thickness, an object scales by its field', () => {
4
- expect(nodeSizeOf(9)).toEqual({ width: 'uniform', px: 9 })
4
+ expect(nodeSizeOf(9)).toEqual({ width: 'uniform', px: 9, links: 'uniform' })
5
5
  expect(nodeSizeOf({ field: 'depth', value: 4 })).toEqual({
6
6
  width: 'depth',
7
7
  px: 4,
8
+ links: 'uniform',
9
+ })
10
+ expect(nodeSizeOf(undefined)).toEqual({
11
+ width: 'depth',
12
+ px: 6,
13
+ links: 'uniform',
14
+ })
15
+ expect(nodeSizeOf({ value: 5, links: 'walks' })).toEqual({
16
+ width: 'uniform',
17
+ px: 5,
18
+ links: 'walks',
8
19
  })
9
- expect(nodeSizeOf(undefined)).toEqual({ width: 'depth', px: 6 })
10
20
  })
11
21
 
12
- test.each(['depth', 'uniform'] as const)('%s survives the trip', width => {
13
- expect(nodeSizeOf(sizeOfNodeWidth(width, 5))).toEqual({ width, px: 5 })
22
+ test.each([
23
+ ['depth', 'uniform'],
24
+ ['uniform', 'uniform'],
25
+ ['depth', 'walks'],
26
+ ['uniform', 'walks'],
27
+ ] as const)('nodes %s, links %s survive the trip', (width, links) => {
28
+ const size = { width, px: 5, links }
29
+ expect(nodeSizeOf(sizeOf(size))).toEqual(size)
14
30
  })
@@ -1,25 +1,41 @@
1
1
  import { isRecord } from '../isRecord'
2
2
 
3
+ import type { EdgeWidth } from '@jbrowse/bandage-core/edgeWidths'
3
4
  import type { NodeWidth } from '@jbrowse/bandage-core/nodeWidths'
4
5
 
5
- // The node thickness as a config or session writes it: a px number draws every
6
+ // The mark thickness as a config or session writes it: a px number draws every
6
7
  // node that thick; `{ field: 'depth', value }` thickens a node the more paths
7
- // carry it, around `value` px. Unset is by depth around 6.
8
- export type NodeSize = number | { field?: 'depth'; value?: number }
8
+ // carry it, around `value` px; `links: 'walks'` thickens a link the more walks
9
+ // take it. Unset is nodes by depth around 6, links uniform.
10
+ export type NodeSize =
11
+ number | { field?: 'depth'; value?: number; links?: 'walks' }
9
12
 
10
13
  const DEFAULT_PX = 6
11
14
 
12
- export function nodeSizeOf(size: unknown): { width: NodeWidth; px: number } {
15
+ export interface MarkSize {
16
+ width: NodeWidth
17
+ px: number
18
+ links: EdgeWidth
19
+ }
20
+
21
+ export function nodeSizeOf(size: unknown): MarkSize {
13
22
  return typeof size === 'number'
14
- ? { width: 'uniform', px: size }
23
+ ? { width: 'uniform', px: size, links: 'uniform' }
15
24
  : isRecord(size)
16
25
  ? {
17
26
  width: size.field === 'depth' ? 'depth' : 'uniform',
18
27
  px: typeof size.value === 'number' ? size.value : DEFAULT_PX,
28
+ links: size.links === 'walks' ? 'walks' : 'uniform',
19
29
  }
20
- : { width: 'depth', px: DEFAULT_PX }
30
+ : { width: 'depth', px: DEFAULT_PX, links: 'uniform' }
21
31
  }
22
32
 
23
- export function sizeOfNodeWidth(width: NodeWidth, px: number): NodeSize {
24
- return width === 'depth' ? { field: 'depth', value: px } : px
33
+ export function sizeOf({ width, px, links }: MarkSize): NodeSize {
34
+ return width === 'uniform' && links === 'uniform'
35
+ ? px
36
+ : {
37
+ ...(width === 'depth' ? { field: 'depth' as const } : {}),
38
+ value: px,
39
+ ...(links === 'walks' ? { links } : {}),
40
+ }
25
41
  }