jbrowse-plugin-graphgenomeviewer 7.4.4 → 7.5.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 (129) hide show
  1. package/dist/chunks/{GbzBaseSyntenyAdapter-A5MNPCKX.js → GbzBaseSyntenyAdapter-4KVWVUN6.js} +2 -2
  2. package/dist/chunks/{GbzBaseSyntenyAdapter-A5MNPCKX.js.map → GbzBaseSyntenyAdapter-4KVWVUN6.js.map} +1 -1
  3. package/dist/chunks/{GraphGenomeView-SEAPDRXS.js → GraphGenomeView-KI4FQ5A7.js} +2 -2
  4. package/dist/chunks/GraphTrackSettingsDialog-UQNMDXTE.js +4 -0
  5. package/dist/chunks/GraphTrackSettingsDialog-UQNMDXTE.js.map +7 -0
  6. package/dist/chunks/{HighlightColorDialog-QAYWLO5I.js → HighlightColorDialog-D4RCUCUF.js} +2 -2
  7. package/dist/chunks/LinearGraphDisplay-RPZUZSM2.js +2 -0
  8. package/dist/chunks/{LinearGraphDisplay-ZC4BQ27J.js.map → LinearGraphDisplay-RPZUZSM2.js.map} +2 -2
  9. package/dist/chunks/{MinigraphBubbleAdapter-3QM4XV47.js → MinigraphBubbleAdapter-TJYI7KEN.js} +2 -2
  10. package/dist/chunks/RgfaTabixAdapter-5ZMJLFZP.js +2 -0
  11. package/dist/chunks/RgfaTabixAdapter-5ZMJLFZP.js.map +7 -0
  12. package/dist/chunks/{WalkFormsAdapter-FT2SW4H6.js → WalkFormsAdapter-CAJOWOEX.js} +2 -2
  13. package/dist/chunks/WalkTabixSyntenyAdapter-VI3KA2GX.js +2 -0
  14. package/dist/chunks/{WalkTabixSyntenyAdapter-3WRVNDLB.js.map → WalkTabixSyntenyAdapter-VI3KA2GX.js.map} +2 -2
  15. package/dist/chunks/{chunk-6WUWQX2P.js → chunk-22ANI2KL.js} +2 -2
  16. package/dist/chunks/{chunk-6WUWQX2P.js.map → chunk-22ANI2KL.js.map} +1 -1
  17. package/dist/chunks/chunk-42KD4AVY.js +2 -0
  18. package/dist/chunks/{chunk-HCDKP3IP.js.map → chunk-42KD4AVY.js.map} +4 -4
  19. package/dist/chunks/chunk-4NIWMRKH.js +2 -0
  20. package/dist/chunks/{chunk-S333HOG5.js.map → chunk-4NIWMRKH.js.map} +4 -4
  21. package/dist/chunks/chunk-BCOXRUNQ.js +3 -0
  22. package/dist/chunks/chunk-BCOXRUNQ.js.map +7 -0
  23. package/dist/chunks/chunk-DAPI7M4M.js +4 -0
  24. package/dist/chunks/chunk-DAPI7M4M.js.map +7 -0
  25. package/dist/chunks/{chunk-G53BI7VK.js → chunk-DEWZOJSC.js} +2 -2
  26. package/dist/chunks/{chunk-G53BI7VK.js.map → chunk-DEWZOJSC.js.map} +1 -1
  27. package/dist/chunks/{chunk-6SWXMRAV.js → chunk-FCMXEFJA.js} +1 -1
  28. package/dist/chunks/{chunk-6SWXMRAV.js.map → chunk-FCMXEFJA.js.map} +1 -1
  29. package/dist/chunks/{chunk-P3FBI5ML.js → chunk-GYY6LF5L.js} +1 -1
  30. package/dist/chunks/{chunk-P3FBI5ML.js.map → chunk-GYY6LF5L.js.map} +1 -1
  31. package/dist/chunks/{chunk-HF4H6LQI.js → chunk-HHMJNAA3.js} +2 -2
  32. package/dist/chunks/{chunk-XXBE4IFZ.js → chunk-HTTONSO4.js} +2 -2
  33. package/dist/chunks/{chunk-XXBE4IFZ.js.map → chunk-HTTONSO4.js.map} +1 -1
  34. package/dist/chunks/{chunk-MUONTMYZ.js → chunk-K4JL57NR.js} +2 -2
  35. package/dist/chunks/chunk-KOC23YNN.js +4 -0
  36. package/dist/chunks/chunk-KOC23YNN.js.map +7 -0
  37. package/dist/chunks/{chunk-OJWBLPPH.js → chunk-LTNFVL2F.js} +2 -2
  38. package/dist/chunks/chunk-OZEKEYT3.js +2 -0
  39. package/dist/chunks/{chunk-6VNU7SD4.js.map → chunk-OZEKEYT3.js.map} +4 -4
  40. package/dist/chunks/{chunk-KIYRMZWP.js → chunk-POUV7OPY.js} +2 -2
  41. package/dist/chunks/{chunk-KIYRMZWP.js.map → chunk-POUV7OPY.js.map} +1 -1
  42. package/dist/chunks/{chunk-LE7FZM6Y.js → chunk-SPL3UO2D.js} +2 -2
  43. package/dist/chunks/{chunk-LE7FZM6Y.js.map → chunk-SPL3UO2D.js.map} +1 -1
  44. package/dist/chunks/chunk-UR2MJJUP.js +2 -0
  45. package/dist/chunks/chunk-VTMJQFN5.js +2 -0
  46. package/dist/chunks/chunk-VTMJQFN5.js.map +7 -0
  47. package/dist/chunks/{chunk-2KVE7YAS.js → chunk-XZGYJ3JC.js} +1 -1
  48. package/dist/chunks/{chunk-2KVE7YAS.js.map → chunk-XZGYJ3JC.js.map} +1 -1
  49. package/dist/chunks/{chunk-7RJMG6I2.js → chunk-Z3IKZAYC.js} +1 -1
  50. package/dist/chunks/{chunk-7RJMG6I2.js.map → chunk-Z3IKZAYC.js.map} +1 -1
  51. package/dist/chunks/{chunk-FTXDTU27.js → chunk-ZV5FNOHS.js} +3 -3
  52. package/dist/chunks/{chunk-FTXDTU27.js.map → chunk-ZV5FNOHS.js.map} +2 -2
  53. package/dist/chunks/{renderSvg-ZIUOPWJQ.js → renderSvg-3PPAEMBT.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 +292 -4
  66. package/src/GraphGenomeView/model.ts +3 -10
  67. package/src/GraphGenomeView/nodeSize.test.ts +21 -5
  68. package/src/GraphGenomeView/nodeSize.ts +24 -8
  69. package/src/GraphGenomeView/pane/fitViews.ts +5 -1
  70. package/src/GraphGenomeView/pane/graphViews.ts +96 -16
  71. package/src/GraphGenomeView/pane/launchActions.ts +55 -11
  72. package/src/GraphGenomeView/pane/launchViews.ts +46 -0
  73. package/src/GraphGenomeView/pane/loadActions.ts +85 -25
  74. package/src/GraphGenomeView/pane/morph.test.ts +25 -1
  75. package/src/GraphGenomeView/pane/morph.ts +3 -1
  76. package/src/GraphGenomeView/pane/paneBase.ts +5 -0
  77. package/src/GraphGenomeView/pane/renderingActions.ts +61 -2
  78. package/src/GraphGenomeView/pane/settingActions.ts +62 -4
  79. package/src/GraphGenomeView/pane/walkRowViews.ts +17 -6
  80. package/src/GraphGenomeView/tubeMapPanels.ts +2 -1
  81. package/src/GraphGenomeView/viewModel.ts +4 -6
  82. package/src/GraphGenomeView/walkRowBlocks.ts +50 -0
  83. package/src/LinearGraphDisplay/LinearGraphDisplay.test.ts +35 -5
  84. package/src/LinearGraphDisplay/components/GraphStatusChrome.test.tsx +18 -0
  85. package/src/LinearGraphDisplay/components/GraphStatusChrome.tsx +4 -2
  86. package/src/LinearGraphDisplay/components/SubgraphHaplotypesField.test.tsx +41 -1
  87. package/src/LinearGraphDisplay/components/SubgraphHaplotypesField.tsx +33 -19
  88. package/src/LinearGraphDisplay/configSchema.ts +4 -3
  89. package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +2 -9
  90. package/src/WalkTabixSyntenyAdapter/WalkTabixSyntenyAdapter.ts +3 -6
  91. package/src/hoverSync/graphViewHighlights.test.ts +53 -0
  92. package/src/hoverSync/graphViewHighlights.ts +25 -0
  93. package/src/hoverSync/lgvHover.test.ts +100 -1
  94. package/src/hoverSync/lgvHover.ts +77 -6
  95. package/src/launchFromGraph/contributors.test.ts +52 -0
  96. package/src/launchFromGraph/contributors.ts +105 -1
  97. package/src/launchFromGraph/launchFromGraph.ts +2 -2
  98. package/src/panSNTabix.test.ts +28 -0
  99. package/src/panSNTabix.ts +26 -8
  100. package/src/version.ts +1 -1
  101. package/dist/chunks/GraphTrackSettingsDialog-EVIIGC5G.js +0 -6
  102. package/dist/chunks/GraphTrackSettingsDialog-EVIIGC5G.js.map +0 -7
  103. package/dist/chunks/LinearGraphDisplay-ZC4BQ27J.js +0 -2
  104. package/dist/chunks/RgfaTabixAdapter-XSOIEWBK.js +0 -2
  105. package/dist/chunks/RgfaTabixAdapter-XSOIEWBK.js.map +0 -7
  106. package/dist/chunks/WalkTabixSyntenyAdapter-3WRVNDLB.js +0 -2
  107. package/dist/chunks/chunk-6VNU7SD4.js +0 -2
  108. package/dist/chunks/chunk-GRTN25B6.js +0 -3
  109. package/dist/chunks/chunk-GRTN25B6.js.map +0 -7
  110. package/dist/chunks/chunk-HCDKP3IP.js +0 -2
  111. package/dist/chunks/chunk-LE43I4PT.js +0 -4
  112. package/dist/chunks/chunk-LE43I4PT.js.map +0 -7
  113. package/dist/chunks/chunk-QV7SSOMJ.js +0 -2
  114. package/dist/chunks/chunk-S333HOG5.js +0 -2
  115. package/dist/chunks/chunk-VZQ6VOUP.js +0 -2
  116. package/dist/chunks/chunk-VZQ6VOUP.js.map +0 -7
  117. package/dist/chunks/chunk-WLRRI35A.js +0 -3
  118. package/dist/chunks/chunk-WLRRI35A.js.map +0 -7
  119. package/src/launchFromGraph/launchTracks.test.ts +0 -95
  120. package/src/launchFromGraph/launchTracks.ts +0 -49
  121. /package/dist/chunks/{GraphGenomeView-SEAPDRXS.js.map → GraphGenomeView-KI4FQ5A7.js.map} +0 -0
  122. /package/dist/chunks/{HighlightColorDialog-QAYWLO5I.js.map → HighlightColorDialog-D4RCUCUF.js.map} +0 -0
  123. /package/dist/chunks/{MinigraphBubbleAdapter-3QM4XV47.js.map → MinigraphBubbleAdapter-TJYI7KEN.js.map} +0 -0
  124. /package/dist/chunks/{WalkFormsAdapter-FT2SW4H6.js.map → WalkFormsAdapter-CAJOWOEX.js.map} +0 -0
  125. /package/dist/chunks/{chunk-HF4H6LQI.js.map → chunk-HHMJNAA3.js.map} +0 -0
  126. /package/dist/chunks/{chunk-MUONTMYZ.js.map → chunk-K4JL57NR.js.map} +0 -0
  127. /package/dist/chunks/{chunk-OJWBLPPH.js.map → chunk-LTNFVL2F.js.map} +0 -0
  128. /package/dist/chunks/{chunk-QV7SSOMJ.js.map → chunk-UR2MJJUP.js.map} +0 -0
  129. /package/dist/chunks/{renderSvg-ZIUOPWJQ.js.map → renderSvg-3PPAEMBT.js.map} +0 -0
@@ -101,12 +101,9 @@ export default class WalkTabixSyntenyAdapter extends ComparativeAdapterBase<Walk
101
101
  * asks for, and the nodes and links they visit
102
102
  */
103
103
  async getSubgraph(region: Region, opts: SubgraphAdapterOptions = {}) {
104
- const refName = await this.refNames.resolve(region, { signal: opts.signal })
105
- if (refName === undefined) {
106
- throw new Error(
107
- `${region.assemblyName} ${region.refName} is not in this graph's index; the graph is cut on ${this.anchor}, mapped to its PanSN prefix in assemblyNameToPanSN`,
108
- )
109
- }
104
+ const refName = await this.refNames.resolveForCut(region, {
105
+ signal: opts.signal,
106
+ })
110
107
  return this.walks.subgraph(refName, region, opts)
111
108
  }
112
109
 
@@ -200,3 +200,56 @@ describe("the drawing view has to be on the highlight's assembly", () => {
200
200
  expect(graphViewHighlights(noAssembly, 'lgv1', ['Sakai'])).toHaveLength(1)
201
201
  })
202
202
  })
203
+
204
+ describe("a hovered node's span on a haplotype's own assembly", () => {
205
+ const onHap = {
206
+ refName: 'CM094351.1',
207
+ start: 1200,
208
+ end: 1500,
209
+ assemblyName: 'HG002.1',
210
+ }
211
+
212
+ // the stack's rows and a view opened beside are not the graph's pair
213
+ test('draws in a view of that assembly, paired or not', () => {
214
+ const views = [
215
+ graphView({
216
+ connectedViewId: 'lgv-ref',
217
+ assemblyHoverHighlights: [onHap],
218
+ }),
219
+ linearView('lgv-ref'),
220
+ linearView('lgv-hap'),
221
+ ]
222
+ expect(graphViewHighlights(views, 'lgv-hap', ['HG002.1'])).toEqual([
223
+ { key: 'graph1-0', region: onHap },
224
+ ])
225
+ })
226
+
227
+ test('draws nowhere else, nor where the view states no assemblies', () => {
228
+ const views = [graphView({ assemblyHoverHighlights: [onHap] })]
229
+ expect(graphViewHighlights(views, 'lgv1', ['hg38'])).toEqual([])
230
+ expect(graphViewHighlights(views, 'lgv1')).toEqual([])
231
+ })
232
+
233
+ test("comes from a graph track's display too", () => {
234
+ const views = [
235
+ {
236
+ ...linearView('lgv-ref'),
237
+ tracks: [
238
+ {
239
+ displays: [
240
+ {
241
+ id: 'display1',
242
+ type: 'LinearGraphDisplay',
243
+ assemblyHoverHighlights: [onHap],
244
+ },
245
+ ],
246
+ },
247
+ ],
248
+ },
249
+ linearView('lgv-hap'),
250
+ ]
251
+ expect(graphViewHighlights(views, 'lgv-hap', ['HG002.1'])).toEqual([
252
+ { key: 'display1-0', region: onHap },
253
+ ])
254
+ })
255
+ })
@@ -124,6 +124,31 @@ export function graphViewHighlights(
124
124
  }
125
125
  }
126
126
  }
127
+ // A hovered node's span on each haplotype's own assembly goes to any
128
+ // view of that assembly, paired or not: the assembly name says where it
129
+ // belongs, and those views are a synteny stack's rows or opened beside
130
+ for (const graph of isRecord(view)
131
+ ? [
132
+ ...trackGraphs(view),
133
+ ...(view.type === 'GraphGenomeView' ? [view] : []),
134
+ ]
135
+ : []) {
136
+ const spans = Array.isArray(graph.assemblyHoverHighlights)
137
+ ? graph.assemblyHoverHighlights
138
+ : []
139
+ spans.forEach((value: unknown, i) => {
140
+ const region = readRegion(value)
141
+ if (
142
+ region?.assemblyName !== undefined &&
143
+ linearAssemblyNames?.includes(region.assemblyName)
144
+ ) {
145
+ highlights.push({
146
+ key: `${typeof graph.id === 'string' ? graph.id : 'graph'}-${i}`,
147
+ region,
148
+ })
149
+ }
150
+ })
151
+ }
127
152
  if (
128
153
  isRecord(view) &&
129
154
  view.type === 'GraphGenomeView' &&
@@ -1,5 +1,13 @@
1
- import { hoverInRegion, nodeForLgvHover, readLgvHover } from './lgvHover'
1
+ import {
2
+ bubbleForLgvHover,
3
+ hoverFromPair,
4
+ hoverInRegion,
5
+ nodeForLgvHover,
6
+ readLgvHover,
7
+ readSelectedFeature,
8
+ } from './lgvHover'
2
9
 
10
+ import type { MinigraphBubble } from '@jbrowse/bandage-core/bubbles/bubbleLine'
3
11
  import type { GraphNode } from '@jbrowse/bandage-core/types'
4
12
 
5
13
  // The shape LinearGenomeViewContainer writes on mousemove: `hoverPosition` is a
@@ -200,3 +208,94 @@ test('a coordinate past the graph resolves to nothing', () => {
200
208
  nodeForLgvHover({ hover: { refName: 'chr1', coord: 9999 }, nodes: NODES }),
201
209
  ).toBeNull()
202
210
  })
211
+
212
+ describe('the bubble a linear-view feature names', () => {
213
+ const bubble = (over: Partial<MinigraphBubble>): MinigraphBubble => ({
214
+ refName: 'chr1',
215
+ start: 5,
216
+ end: 8,
217
+ segmentCount: 3,
218
+ pathCount: 2,
219
+ inversion: false,
220
+ shortestAlleleLength: 0,
221
+ longestAlleleLength: 2,
222
+ segments: '',
223
+ shortestAllele: undefined,
224
+ longestAllele: undefined,
225
+ ...over,
226
+ })
227
+ const indexed = bubble({ segments: 's10,s11,s12' })
228
+ const derived = bubble({ segments: 's21,s20,s22', key: 's20>s22' })
229
+ const bubbles = [indexed, derived]
230
+ const at = { refName: 'chr1', coord: 6 }
231
+
232
+ test("is the indexed bubble with a bubble feature's segments", () => {
233
+ expect(
234
+ bubbleForLgvHover(
235
+ { ...at, featureSegments: 's10,s11,s12', featureName: '2 bp' },
236
+ bubbles,
237
+ ),
238
+ ).toBe(indexed)
239
+ })
240
+
241
+ // vg deconstruct writes the snarl's ends as the record ID
242
+ test('is the bubble a graph-derived VCF record names, either way round', () => {
243
+ expect(bubbleForLgvHover({ ...at, featureName: '>s20>s22' }, bubbles)).toBe(
244
+ derived,
245
+ )
246
+ expect(bubbleForLgvHover({ ...at, featureName: '<s22<s20' }, bubbles)).toBe(
247
+ derived,
248
+ )
249
+ expect(bubbleForLgvHover({ ...at, featureName: '>s10>s12' }, bubbles)).toBe(
250
+ indexed,
251
+ )
252
+ })
253
+
254
+ test('is nothing for a feature that names no bubble', () => {
255
+ expect(bubbleForLgvHover({ ...at, featureName: 'rs123' }, bubbles)).toBe(
256
+ undefined,
257
+ )
258
+ expect(bubbleForLgvHover({ ...at, featureName: '>s20>s99' }, bubbles)).toBe(
259
+ undefined,
260
+ )
261
+ })
262
+
263
+ test('a selected feature reads as a hover at its start', () => {
264
+ const fields: Record<string, unknown> = {
265
+ refName: 'chr1',
266
+ start: 5,
267
+ name: '>s20>s22',
268
+ }
269
+ expect(
270
+ readSelectedFeature({ get: (key: string) => fields[key] }),
271
+ ).toMatchObject({ refName: 'chr1', coord: 5, featureName: '>s20>s22' })
272
+ expect(readSelectedFeature(undefined)).toBeUndefined()
273
+ })
274
+ })
275
+
276
+ // Two linear views on one locus: the graph paired with A must not light for
277
+ // a hover in B
278
+ describe('a hover from another linear view', () => {
279
+ const at = (viewId?: string) => ({ refName: 'chr1', coord: 3, viewId })
280
+ const live = new Set(['lgvA', 'lgvB'])
281
+
282
+ test('is read off the hover payload', () => {
283
+ expect(
284
+ readLgvHover({
285
+ hoverPosition: { refName: 'chr1', coord: 4 },
286
+ viewId: 'lgvB',
287
+ })?.viewId,
288
+ ).toBe('lgvB')
289
+ })
290
+
291
+ test('is ignored by a graph paired with an open view', () => {
292
+ expect(hoverFromPair(at('lgvA'), 'lgvA', live)).toBe(true)
293
+ expect(hoverFromPair(at('lgvB'), 'lgvA', live)).toBe(false)
294
+ })
295
+
296
+ test('reaches an unpaired graph, one whose pair closed, or from an older host', () => {
297
+ expect(hoverFromPair(at('lgvB'), undefined, live)).toBe(true)
298
+ expect(hoverFromPair(at('lgvB'), 'gone', live)).toBe(true)
299
+ expect(hoverFromPair(at(), 'lgvA', live)).toBe(true)
300
+ })
301
+ })
@@ -3,6 +3,7 @@ import { isBackbone } from '@jbrowse/bandage-core/anchoredNodes'
3
3
  import { isRecord } from '../isRecord'
4
4
 
5
5
  import type { SubgraphRegion } from '../GetSubgraph'
6
+ import type { MinigraphBubble } from '@jbrowse/bandage-core/bubbles/bubbleLine'
6
7
  import type { GraphNode } from '@jbrowse/bandage-core/types'
7
8
 
8
9
  // What a LinearGenomeView writes to `session.hovered` on every mousemove (see
@@ -33,6 +34,11 @@ export interface LgvHover {
33
34
  // hover that states no assembly is taken at its word rather than dropped.
34
35
  assemblyName?: string
35
36
  featureName?: string
37
+ // a bubble feature's comma-separated segment names (MinigraphBubbleAdapter)
38
+ featureSegments?: string
39
+ // the linear view hovered, which hosts from jbrowse-components b49f05f438
40
+ // state
41
+ viewId?: string
36
42
  }
37
43
 
38
44
  interface FeatureLike {
@@ -46,13 +52,13 @@ function isFeatureLike(value: unknown): value is FeatureLike {
46
52
  // A display hovers either a Feature, read with `get`, or a plain hit item with
47
53
  // a `name` of its own, which is what the canvas feature display hands over.
48
54
  // Reading only the first left the exact segment match dead on that display.
49
- function hoveredName(feature: unknown) {
50
- const name = isFeatureLike(feature)
51
- ? feature.get('name')
55
+ function hoveredField(feature: unknown, key: string) {
56
+ const value = isFeatureLike(feature)
57
+ ? feature.get(key)
52
58
  : isRecord(feature)
53
- ? feature.name
59
+ ? feature[key]
54
60
  : undefined
55
- return typeof name === 'string' ? name : undefined
61
+ return typeof value === 'string' ? value : undefined
56
62
  }
57
63
 
58
64
  // `hovered` is typed `unknown` on the session by design — it is a shared channel
@@ -71,13 +77,49 @@ export function readLgvHover(hovered: unknown): LgvHover | undefined {
71
77
  coord: typeof coord0 === 'number' ? coord0 : coord - 1,
72
78
  assemblyName:
73
79
  typeof assemblyName === 'string' ? assemblyName : undefined,
74
- featureName: hoveredName(hovered.hoverFeature),
80
+ featureName: hoveredField(hovered.hoverFeature, 'name'),
81
+ featureSegments: hoveredField(hovered.hoverFeature, 'segments'),
82
+ viewId: typeof hovered.viewId === 'string' ? hovered.viewId : undefined,
75
83
  }
76
84
  }
77
85
  }
78
86
  return result
79
87
  }
80
88
 
89
+ // Whether a hover may reach a graph paired with `paired`: one naming another
90
+ // view does not, while the paired view is still open. A hover naming no view,
91
+ // from an older host, and a graph whose pair has closed take any.
92
+ export function hoverFromPair(
93
+ hover: LgvHover,
94
+ paired: string | undefined,
95
+ liveViewIds: ReadonlySet<string>,
96
+ ) {
97
+ return (
98
+ hover.viewId === undefined ||
99
+ paired === undefined ||
100
+ !liveViewIds.has(paired) ||
101
+ hover.viewId === paired
102
+ )
103
+ }
104
+
105
+ // The feature a click in a linear view selected (`session.selection`, which
106
+ // core's feature widget sets), read as a hover at its start
107
+ export function readSelectedFeature(selection: unknown): LgvHover | undefined {
108
+ if (!isFeatureLike(selection)) {
109
+ return undefined
110
+ }
111
+ const refName = selection.get('refName')
112
+ const start = selection.get('start')
113
+ return typeof refName === 'string' && typeof start === 'number'
114
+ ? {
115
+ refName,
116
+ coord: start,
117
+ featureName: hoveredField(selection, 'name'),
118
+ featureSegments: hoveredField(selection, 'segments'),
119
+ }
120
+ : undefined
121
+ }
122
+
81
123
  // Whether a hover is one this graph can answer: the same assembly, the same
82
124
  // stable sequence, and inside the window the cut was made for.
83
125
  //
@@ -130,3 +172,32 @@ export function nodeForLgvHover({
130
172
  // An exact segment match wins over the coordinate it happens to sit at.
131
173
  return byName ?? byCoord ?? null
132
174
  }
175
+
176
+ // A bubble's two end segments, unordered: a derived bubble's key is
177
+ // `source>sink`, and an indexed one lists its segments source first
178
+ function bubbleEnds(b: Pick<MinigraphBubble, 'key' | 'segments'>) {
179
+ const ids = b.key?.split('>') ?? b.segments.split(',').filter(Boolean)
180
+ return ids.length >= 2 ? [ids[0]!, ids.at(-1)!].sort().join('\t') : undefined
181
+ }
182
+
183
+ // The ends a hovered feature names: a bubble feature's segments, or a vg
184
+ // deconstruct record's ID, `>source>sink` (`<sink<source` on the reverse)
185
+ function featureEnds(hover: LgvHover) {
186
+ if (hover.featureSegments) {
187
+ return bubbleEnds({ segments: hover.featureSegments })
188
+ }
189
+ const ids = /^([<>][^<>]+){2}$/.test(hover.featureName ?? '')
190
+ ? hover.featureName!.split(/[<>]/).filter(Boolean)
191
+ : []
192
+ return ids.length === 2 ? ids.sort().join('\t') : undefined
193
+ }
194
+
195
+ // The graph's bubble a linear-view feature names, so a bubble track or a
196
+ // graph-derived VCF lights the whole bubble rather than the backbone node at
197
+ // the pointer's bp
198
+ export function bubbleForLgvHover(hover: LgvHover, bubbles: MinigraphBubble[]) {
199
+ const ends = featureEnds(hover)
200
+ return ends === undefined
201
+ ? undefined
202
+ : bubbles.find(b => bubbleEnds(b) === ends)
203
+ }
@@ -1,8 +1,10 @@
1
+ import { PathVisits } from '@jbrowse/bandage-core/pathVisits'
1
2
  import { expect, test } from 'vitest'
2
3
 
3
4
  import {
4
5
  contributingAssemblies,
5
6
  locString,
7
+ nodeAssemblySpans,
6
8
  nodeOwnLocation,
7
9
  resolveContributors,
8
10
  resolveLocationAssembly,
@@ -141,6 +143,27 @@ test('a contributor resolves through an assembly alias, under its own name', ()
141
143
  ).toMatchObject({ refName: 'chr17', start: 83899576, end: 84041803 })
142
144
  })
143
145
 
146
+ test('two haplotypes on one contig name do not share a bare-sample assembly', () => {
147
+ const contributors = contributingAssemblies(
148
+ graph([
149
+ node('s1', 'GRCh38#0#chr6', 31000000, 5000, 0),
150
+ node('s2', 'HG002#1#chr6', 31100000, 300, 1),
151
+ node('s3', 'HG002#2#chr6', 31050000, 300, 1),
152
+ ]),
153
+ )
154
+ const bySample = (sample: string) =>
155
+ ({ HG002: 'HG002', 'HG002#2': 'HG002.2', GRCh38: 'hg38' })[sample]
156
+ expect(
157
+ resolveContributors(contributors, bySample).map(c => [
158
+ c.sample,
159
+ c.haplotype,
160
+ ]),
161
+ ).toEqual([
162
+ ['hg38', 'GRCh38#0'],
163
+ ['HG002.2', 'HG002#2'],
164
+ ])
165
+ })
166
+
144
167
  test('a locstring is 1-based inclusive', () => {
145
168
  expect(
146
169
  locString({
@@ -201,3 +224,32 @@ test('a haplotype-named graph contributes per haplotype, resolved at that depth
201
224
  resolveLocationAssembly(bySample, { sample: 'K12', haplotype: undefined }),
202
225
  ).toBeUndefined()
203
226
  })
227
+
228
+ test("a node's spans are where each walk through it passes it, on loaded assemblies", () => {
229
+ const allele = node('s7', 'HG002#1#CM1', 500, 300, 1)
230
+ const g = {
231
+ ...graph([allele]),
232
+ pathVisits: PathVisits.from([
233
+ [
234
+ 's7',
235
+ [
236
+ {
237
+ path: 'GRCh38#0#chr6',
238
+ sample: 'GRCh38',
239
+ start: 31000,
240
+ strand: '+',
241
+ },
242
+ { path: 'HG002#1#CM1', sample: 'HG002', start: 500, strand: '+' },
243
+ { path: 'HG002#2#CM2', sample: 'HG002', start: 9100, strand: '-' },
244
+ { path: 'NA1#1#chr6', sample: 'NA1', start: 40, strand: '+' },
245
+ ],
246
+ ],
247
+ ]),
248
+ }
249
+ const resolve = (sample: string) =>
250
+ ({ GRCh38: 'hg38', 'HG002#1': 'HG002.1', 'HG002#2': 'HG002.2' })[sample]
251
+ expect(nodeAssemblySpans(g, allele, resolve, 'hg38')).toEqual([
252
+ { assemblyName: 'HG002.1', refName: 'CM1', start: 500, end: 800 },
253
+ { assemblyName: 'HG002.2', refName: 'CM2', start: 9100, end: 9400 },
254
+ ])
255
+ })
@@ -44,6 +44,94 @@ function anchoredNodeLocation(node: AnchoredNode): GraphLocation {
44
44
  }
45
45
  }
46
46
 
47
+ export interface AssemblySpan {
48
+ assemblyName: string
49
+ refName: string
50
+ start: number
51
+ end: number
52
+ }
53
+
54
+ // Where a node's sequence sits on each loaded assembly: its own rGFA
55
+ // location, and where every walk through it passes it on that walk's contig.
56
+ // `except` is the assembly the reference band is already drawn on.
57
+ export function nodeAssemblySpans(
58
+ graph: Graph,
59
+ node: GraphNode,
60
+ resolve: (sample: string) => string | undefined,
61
+ except?: string,
62
+ ) {
63
+ const spans = new Map<string, AssemblySpan>()
64
+ const add = (loc: GraphLocation) => {
65
+ const assemblyName = resolveLocationAssembly(resolve, loc)
66
+ const key = `${assemblyName}\t${loc.refName}\t${loc.start}`
67
+ if (assemblyName !== undefined && assemblyName !== except) {
68
+ spans.set(key, {
69
+ assemblyName,
70
+ refName: loc.refName,
71
+ start: loc.start,
72
+ end: loc.end,
73
+ })
74
+ }
75
+ }
76
+ const own = nodeOwnLocation(node)
77
+ if (own) {
78
+ add(own)
79
+ }
80
+ for (const visit of graph.pathVisits?.get(node.name) ?? []) {
81
+ add({
82
+ sample: panSNSample(visit.path),
83
+ haplotype: panSNHaplotype(visit.path),
84
+ refName: panSNContig(visit.path),
85
+ start: visit.start,
86
+ end: visit.start + node.length,
87
+ })
88
+ }
89
+ return [...spans.values()]
90
+ }
91
+
92
+ // Each stable sequence off the reference that the cut's nodes lie on, with
93
+ // the loaded assembly holding it and the span its nodes cover there. A gene
94
+ // read over that span, renamed to `stableRefName`, lands on those nodes the
95
+ // way the reference's genes land on the backbone.
96
+ export function offReferenceContigs(
97
+ graph: Graph,
98
+ resolve: (sample: string) => string | undefined,
99
+ except?: string,
100
+ ) {
101
+ const contigs = new Map<string, AssemblySpan & { stableRefName: string }>()
102
+ // an HPRC cut names hundreds of haplotypes no session loads
103
+ const unloaded = new Set<string>()
104
+ for (const node of graph.nodes) {
105
+ if (!isAnchored(node) || node.stable.rank === 0) {
106
+ continue
107
+ }
108
+ const stableRefName = node.stable.refName
109
+ if (unloaded.has(stableRefName)) {
110
+ continue
111
+ }
112
+ const loc = anchoredNodeLocation(node)
113
+ const seen = contigs.get(stableRefName)
114
+ if (seen) {
115
+ seen.start = Math.min(seen.start, loc.start)
116
+ seen.end = Math.max(seen.end, loc.end)
117
+ continue
118
+ }
119
+ const assemblyName = resolveLocationAssembly(resolve, loc)
120
+ if (assemblyName === undefined || assemblyName === except) {
121
+ unloaded.add(stableRefName)
122
+ } else {
123
+ contigs.set(stableRefName, {
124
+ stableRefName,
125
+ assemblyName,
126
+ refName: loc.refName,
127
+ start: loc.start,
128
+ end: loc.end,
129
+ })
130
+ }
131
+ }
132
+ return [...contigs.values()]
133
+ }
134
+
47
135
  export interface Contributor extends GraphLocation {
48
136
  // lowest rank this assembly holds here: 0 means it is the reference backbone
49
137
  rank: number
@@ -188,12 +276,28 @@ export function resolveLocationAssembly(
188
276
  // among its aliases; matching on names alone leaves that donor unopenable unless
189
277
  // the reader renames their assembly after the graph. The resolved (canonical)
190
278
  // name replaces the sample, so a launch opens the assembly the session knows.
279
+ //
280
+ // A bare sample stands in for a haplotype unless two haplotypes of it share a
281
+ // contig name. HPRC's GenBank contigs differ per haplotype, so an `NA20809`
282
+ // assembly can hold both; two haplotypes each on `chr6` cannot both be it, and
283
+ // opening hap 2's coordinates on hap 1's sequence lands somewhere plausible
284
+ // and wrong.
191
285
  export function resolveContributors(
192
286
  contributors: Contributor[],
193
287
  resolve: (sample: string) => string | undefined,
194
288
  ) {
289
+ const haplotypesOn = new Map<string, number>()
290
+ const contig = (c: Contributor) => `${c.sample}\t${c.refName}`
291
+ for (const c of contributors) {
292
+ if (c.haplotype !== undefined) {
293
+ haplotypesOn.set(contig(c), (haplotypesOn.get(contig(c)) ?? 0) + 1)
294
+ }
295
+ }
195
296
  return contributors.flatMap(c => {
196
- const sample = resolveLocationAssembly(resolve, c)
297
+ const sample =
298
+ (haplotypesOn.get(contig(c)) ?? 0) > 1
299
+ ? resolve(c.haplotype!)
300
+ : resolveLocationAssembly(resolve, c)
197
301
  return sample === undefined ? [] : [{ ...c, sample }]
198
302
  })
199
303
  }
@@ -253,8 +253,8 @@ function assemblyNamesOfTrack(
253
253
  // point of launching from the graph instead of from a linear view.
254
254
  //
255
255
  // Panels carry THE GRAPH'S OWN TRACK and nothing else, which is a narrower rule
256
- // than the single-view launch's (see launchTracks, which scans the session for
257
- // the assembly's annotation). Tried that here: a gene track per panel takes
256
+ // than the single-view launch's (see showInLinearView, which adds the
257
+ // assembly's gene track). Tried that here: a gene track per panel takes
258
258
  // about 160px of a row that is otherwise a ruler, so on five strains the
259
259
  // annotation is most of the viewport and the ribbons — the thing the launch
260
260
  // exists to draw — are squeezed into the gaps, and `collapseEmptyRows` is here
@@ -66,4 +66,32 @@ describe('a region the file holds no name for', () => {
66
66
  refNamesOf(['chr1', 'HG002#1#chr1']).resolveMapped(chrM),
67
67
  ).resolves.toBeUndefined()
68
68
  })
69
+
70
+ // an assemblyNameToPanSN hint would send the user to fix a config that is
71
+ // already right
72
+ test('fails a cut by saying the graph has nothing there', async () => {
73
+ await expect(
74
+ refNamesOf(['chr1', 'HG002#1#chr1']).resolveForCut(chrM),
75
+ ).rejects.toThrow('This graph has nothing on hg38 chrM')
76
+ })
77
+ })
78
+
79
+ // A walk file indexes `chr1~851968`-style detail sequences beside its contigs
80
+ test("a walk file's detail sequences are not contigs", async () => {
81
+ const refNames = new PanSNRefNames(
82
+ {
83
+ getReferenceSequenceNames: () =>
84
+ Promise.resolve(['chr1', 'chr1~851968', 'chr2', 'chr2~65536']),
85
+ } as never,
86
+ { getConf: () => undefined } as never,
87
+ )
88
+ expect(await refNames.assemblyRefNames()).toEqual(['chr1', 'chr2'])
89
+ expect(
90
+ await refNames.resolve({
91
+ assemblyName: 'hg38',
92
+ refName: 'chr1~851968',
93
+ start: 0,
94
+ end: 1,
95
+ }),
96
+ ).toBeUndefined()
69
97
  })
package/src/panSNTabix.ts CHANGED
@@ -96,24 +96,23 @@ export function openTabixSlot(
96
96
  })
97
97
  }
98
98
 
99
+ export function unmappedRegionMessage(region: Region) {
100
+ return `${region.assemblyName} ${region.refName} is not in this graph's index; a graph with PanSN names (GRCh38#0#chr1) needs ${region.assemblyName} mapped to its prefix in assemblyNameToPanSN`
101
+ }
102
+
99
103
  /**
100
104
  * The reference-name half of a tabix graph track, against one indexed file.
101
105
  *
102
106
  * Constructed with the file the adapter's own slots opened, so this never reads
103
107
  * a slot whose name it cannot prove.
104
108
  */
105
- export function unmappedRegionMessage(region: Region) {
106
- return `${region.assemblyName} ${region.refName} is not in this graph's index; a graph with PanSN names (GRCh38#0#chr1) needs ${region.assemblyName} mapped to its prefix in assemblyNameToPanSN`
107
- }
108
-
109
109
  export class PanSNRefNames {
110
110
  // Read once and shared by every query, so it takes on no caller's signal: a
111
111
  // track fetch the user panned away from once aborted the read a graph cut
112
112
  // was waiting on. cachedSetup rejects only the caller that aborts, keeps the
113
113
  // read going for the rest, and retries a failed read.
114
114
  private lookup = cachedSetup({
115
- setup: async opts =>
116
- refNameLookup(await this.file.getReferenceSequenceNames(opts)),
115
+ setup: async opts => refNameLookup(await this.names(opts)),
117
116
  })
118
117
 
119
118
  constructor(
@@ -121,6 +120,13 @@ export class PanSNRefNames {
121
120
  private adapter: BaseFeatureDataAdapter,
122
121
  ) {}
123
122
 
123
+ // A walk file also indexes `<seq>~<chunkStart>` detail sequences, which name
124
+ // no contig
125
+ private async names(opts?: BaseOptions) {
126
+ const names = await this.file.getReferenceSequenceNames(opts)
127
+ return names.filter(n => !n.includes('~'))
128
+ }
129
+
124
130
  /**
125
131
  * The names the ASSEMBLY uses, not the graph's own.
126
132
  *
@@ -132,7 +138,7 @@ export class PanSNRefNames {
132
138
  * were spotted.
133
139
  */
134
140
  async assemblyRefNames(opts: BaseOptions = {}) {
135
- const names = await this.file.getReferenceSequenceNames(opts)
141
+ const names = await this.names(opts)
136
142
  const prefix = resolvePanSNPrefix(this.adapter, opts.assemblyName)
137
143
  const contigs = names
138
144
  .filter(n => panSNMatchesPrefix(n, prefix))
@@ -167,7 +173,7 @@ export class PanSNRefNames {
167
173
  async resolveMapped(region: Region, opts?: BaseOptions) {
168
174
  const refName = await this.resolve(region, opts)
169
175
  if (refName === undefined && region.assemblyName) {
170
- const names = await this.file.getReferenceSequenceNames(opts)
176
+ const names = await this.names(opts)
171
177
  const prefix = resolvePanSNPrefix(this.adapter, region.assemblyName)
172
178
  if (
173
179
  names.every(n => n.includes('#')) &&
@@ -179,6 +185,18 @@ export class PanSNRefNames {
179
185
  return refName
180
186
  }
181
187
 
188
+ // The name a graph cut reads, failing with what is actually wrong: an
189
+ // unmapped assembly, or a contig such as chrM the graph has nothing on
190
+ async resolveForCut(region: Region, opts?: BaseOptions) {
191
+ const refName = await this.resolveMapped(region, opts)
192
+ if (refName === undefined) {
193
+ throw new Error(
194
+ `This graph has nothing on ${region.assemblyName} ${region.refName}`,
195
+ )
196
+ }
197
+ return refName
198
+ }
199
+
182
200
  /**
183
201
  * The compressed bytes a read of `regions` fetches from the file, as its
184
202
  * index estimates them, for the display's byte gate. `queryStart` moves a
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const version = '7.4.4'
1
+ export const version = '7.5.0'
@@ -1,6 +0,0 @@
1
- import{a as G,b as L}from"./chunk-6VNU7SD4.js";import{b as v,c as x,n as d}from"./chunk-7RJMG6I2.js";import"./chunk-2KVE7YAS.js";import"./chunk-S333HOG5.js";import"./chunk-LE7FZM6Y.js";import"./chunk-JRJHXLGP.js";import"./chunk-6SWXMRAV.js";import"./chunk-IKJZPPOA.js";import{g,h}from"./chunk-YU7T5WOQ.js";import{F as m,H as u,n as C}from"./chunk-UGCNPRKC.js";import{b as r,c as i,d as c}from"./chunk-JMBDTNUR.js";var P=d()({section:{marginBottom:24}}),T=[{value:"0",label:"None"},{value:"1",label:"1 hop"},{value:"2",label:"2 hops"}],w=c(function({model:t}){let{classes:l}=P(),o=t.sourceAdapter;return o?.type==="RgfaTabixAdapter"&&!v(o)?i("div",{className:l.section,children:[r(G,{label:"Graph context",sx:{minWidth:200},value:String(t.subgraphContext),options:T,testId:"graph-context-select",onChange:a=>{t.setSubgraphContext(Number(a)),t.recut()}}),r(u,{variant:"caption",component:"div",color:"text.secondary",children:"How far the cut follows links out of the region. At none, a detour that leaves the window draws as two stubs. No effect on gfa-to-tabix's default anchored index."})]}):null}),k=w;var F=d()({section:{marginBottom:24}});function M(n){return n.split(/[\s,]+/).filter(t=>t!=="")}var I=c(function({model:t}){let[l,o]=h(t.cutHaplotypes?.join(", ")??""),a=()=>{let e=M(l);e.join(`
2
- `)===(t.cutHaplotypes??[]).join(`
3
- `)||(t.setSubgraphHaplotypes(e),t.recut())};return r(m,{fullWidth:!0,label:"Haplotypes",placeholder:"every haplotype",value:l,slotProps:{htmlInput:{"data-testid":"graph-haplotypes-field"}},onChange:e=>{o(e.target.value)},onBlur:()=>{a()},onKeyDown:e=>{e.key==="Enter"&&a()}})}),A=c(function({model:t,names:l}){let[o,a]=h(t.cutHaplotypes??[]),e=g(o),f=g(""),[b,S]=h(!1),H=p=>{p.join(`
4
- `)!==(t.cutHaplotypes??[]).join(`
5
- `)&&(t.setSubgraphHaplotypes(p),t.recut())};return r(C,{multiple:!0,freeSolo:!0,disableCloseOnSelect:!0,options:l,value:o,open:b,onOpen:()=>{S(!0)},onInputChange:(p,s)=>{f.current=s},onClose:(p,s)=>{S(!1);let y=f.current.trim();s==="blur"&&y&&!e.current.includes(y)&&(e.current=[...e.current,y],a(e.current)),H(e.current)},onChange:(p,s)=>{e.current=s,a(s),b||H(s)},renderInput:p=>r(m,{...p,label:"Haplotypes",placeholder:o.length===0?"every haplotype":void 0,slotProps:{...p.slotProps,htmlInput:{...p.slotProps.htmlInput,"data-testid":"graph-haplotypes-field"}}})})}),D=c(function({model:t}){let{classes:l}=F(),o=t.haplotypeNames,a=t.cutHaplotypes?.join(",")??"",e=t.sourceAdapter;return e&&x(e)?i("div",{className:l.section,children:[o?r(A,{model:t,names:o},a):r(I,{model:t},a),i(u,{variant:"caption",component:"div",color:"text.secondary",children:[o?`Which of the graph's ${o.length.toLocaleString()} haplotypes to cut, or a PanSN prefix (HG002 for both).`:"Which haplotypes to cut, as lane assembly names or PanSN prefixes (HG002#1, or HG002 for both) separated by commas."," ","Empty is every haplotype."]})]}):null}),N=D;function B({model:n,onClose:t}){return i(L,{model:n,onClose:t,children:[r(k,{model:n}),r(N,{model:n})]})}export{B as default};
6
- //# sourceMappingURL=GraphTrackSettingsDialog-EVIIGC5G.js.map