jbrowse-plugin-graphgenomeviewer 6.9.0 → 6.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/LinearGraphDisplay-ZJNR2KCT.js +2 -0
- package/dist/chunks/LinearGraphDisplay-ZJNR2KCT.js.map +7 -0
- package/dist/chunks/RgfaTabixAdapter-KOIMYOR3.js +4 -0
- package/dist/chunks/RgfaTabixAdapter-KOIMYOR3.js.map +7 -0
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +1 -1
- package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +2 -2
- package/package.json +2 -1
- package/src/LinearGraphDisplay/components/GraphStatusChrome.test.tsx +78 -0
- package/src/LinearGraphDisplay/components/GraphStatusChrome.tsx +104 -0
- package/src/LinearGraphDisplay/components/LinearGraphDisplay.tsx +3 -3
- package/src/RgfaTabixAdapter/RgfaTabixAdapter.test.ts +12 -0
- package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +52 -21
- package/src/RgfaTabixAdapter/configSchema.ts +12 -9
- package/src/RgfaTabixAdapter/test_data/walks_maxnode.links.bed.gz +0 -0
- package/src/RgfaTabixAdapter/test_data/walks_maxnode.links.bed.gz.tbi +0 -0
- package/src/RgfaTabixAdapter/test_data/walks_maxnode.nodes.bed.gz +0 -0
- package/src/RgfaTabixAdapter/test_data/walks_maxnode.nodes.bed.gz.tbi +0 -0
- package/src/RgfaTabixAdapter/test_data/walks_maxnode.walks.bed.gz +0 -0
- package/src/RgfaTabixAdapter/test_data/walks_maxnode.walks.bed.gz.tbi +0 -0
- package/src/RgfaTabixAdapter/walkFiles.test.ts +90 -0
- package/src/RgfaTabixAdapter/walkRows.test.ts +53 -0
- package/src/RgfaTabixAdapter/walkRows.ts +38 -16
- package/src/version.ts +1 -1
- package/dist/chunks/LinearGraphDisplay-HOS4F4V6.js +0 -2
- package/dist/chunks/LinearGraphDisplay-HOS4F4V6.js.map +0 -7
- package/dist/chunks/RgfaTabixAdapter-JUQ64AJS.js +0 -4
- package/dist/chunks/RgfaTabixAdapter-JUQ64AJS.js.map +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jbrowse-plugin-graphgenomeviewer",
|
|
3
|
-
"version": "6.9.
|
|
3
|
+
"version": "6.9.2",
|
|
4
4
|
"description": "JBrowse 2 graph genome (pangenome) view: rGFA reference-anchored layouts, the Bandage force-directed engine, and haplotype lanes read from a gbz-base database",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@jbrowse/cigar-utils": "5.0.0-beta.11",
|
|
32
32
|
"@jbrowse/core": "5.0.0-beta.11",
|
|
33
33
|
"@jbrowse/display-kit": "5.0.0-beta.11",
|
|
34
|
+
"@jbrowse/display-ui": "5.0.0-beta.11",
|
|
34
35
|
"@jbrowse/render-core": "5.0.0-beta.11",
|
|
35
36
|
"@jbrowse/synteny-core": "5.0.0-beta.11"
|
|
36
37
|
},
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
|
|
3
|
+
import GraphStatusChrome from './GraphStatusChrome'
|
|
4
|
+
|
|
5
|
+
import type { LinearGraphDisplayModel } from '../model'
|
|
6
|
+
|
|
7
|
+
function model(over: Record<string, unknown> = {}) {
|
|
8
|
+
return {
|
|
9
|
+
configuration: { displayId: 'graph-LinearGraphDisplay' },
|
|
10
|
+
height: 300,
|
|
11
|
+
error: undefined,
|
|
12
|
+
regionTooLargeReason: '',
|
|
13
|
+
zoomCanReleaseGate: true,
|
|
14
|
+
statusMessage: undefined,
|
|
15
|
+
statusProgress: undefined,
|
|
16
|
+
fetchCanceled: false,
|
|
17
|
+
reload: vi.fn(),
|
|
18
|
+
...over,
|
|
19
|
+
} as unknown as LinearGraphDisplayModel
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('a drawn graph sits in the chrome with its readiness attributes', () => {
|
|
23
|
+
render(
|
|
24
|
+
<GraphStatusChrome
|
|
25
|
+
model={model()}
|
|
26
|
+
phase="ready"
|
|
27
|
+
drawn
|
|
28
|
+
testid="linear-graph-display"
|
|
29
|
+
data-layout="auto"
|
|
30
|
+
>
|
|
31
|
+
<canvas data-testid="graph-canvas" />
|
|
32
|
+
</GraphStatusChrome>,
|
|
33
|
+
)
|
|
34
|
+
const chrome = screen.getByTestId('linear-graph-display')
|
|
35
|
+
expect(chrome.dataset.displayPhase).toBe('ready')
|
|
36
|
+
expect(chrome.dataset.displayDrawn).toBe('true')
|
|
37
|
+
expect(chrome.dataset.layout).toBe('auto')
|
|
38
|
+
expect(screen.getByTestId('graph-canvas')).toBeTruthy()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a refused cut shows its notice over the graph', () => {
|
|
42
|
+
const error = Object.assign(
|
|
43
|
+
new Error('Zoom in to about 54Kbp to see the graph'),
|
|
44
|
+
{ name: 'NodeLimitError', regionTooLarge: true },
|
|
45
|
+
)
|
|
46
|
+
render(
|
|
47
|
+
<GraphStatusChrome
|
|
48
|
+
model={model({ error })}
|
|
49
|
+
phase="error"
|
|
50
|
+
drawn={false}
|
|
51
|
+
testid="linear-graph-display"
|
|
52
|
+
>
|
|
53
|
+
<canvas />
|
|
54
|
+
</GraphStatusChrome>,
|
|
55
|
+
)
|
|
56
|
+
expect(
|
|
57
|
+
screen.getByText(/Zoom in to about 54Kbp to see the graph/),
|
|
58
|
+
).toBeTruthy()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('a window past the bp cap replaces the graph with the zoom-in notice', () => {
|
|
62
|
+
render(
|
|
63
|
+
<GraphStatusChrome
|
|
64
|
+
model={model({
|
|
65
|
+
regionTooLargeReason:
|
|
66
|
+
'Region too large for a graph cut (6 Mb, max 5 Mb)',
|
|
67
|
+
})}
|
|
68
|
+
phase="tooLarge"
|
|
69
|
+
drawn={false}
|
|
70
|
+
testid="linear-graph-display"
|
|
71
|
+
>
|
|
72
|
+
<canvas data-testid="graph-canvas" />
|
|
73
|
+
</GraphStatusChrome>,
|
|
74
|
+
)
|
|
75
|
+
expect(screen.queryByTestId('graph-canvas')).toBeNull()
|
|
76
|
+
expect(screen.getByText(/Region too large for a graph cut/)).toBeTruthy()
|
|
77
|
+
expect(screen.getByText('Force load')).toBeTruthy()
|
|
78
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import DisplayBackgroundProgress from '@jbrowse/display-kit/DisplayBackgroundProgress'
|
|
5
|
+
import DisplayErrorBar from '@jbrowse/display-kit/DisplayErrorBar'
|
|
6
|
+
import DisplayLoadingOverlay from '@jbrowse/display-kit/DisplayLoadingOverlay'
|
|
7
|
+
import TooLargeMessage from '@jbrowse/display-kit/TooLargeMessage'
|
|
8
|
+
import {
|
|
9
|
+
BottomRightCornerContext,
|
|
10
|
+
TrackOverlayPortal,
|
|
11
|
+
useChromeOverlayOverride,
|
|
12
|
+
} from '@jbrowse/display-ui'
|
|
13
|
+
|
|
14
|
+
import type { LinearGraphDisplayModel } from '../model'
|
|
15
|
+
import type { DisplayStatusPhase } from '@jbrowse/render-core/displayPhase'
|
|
16
|
+
|
|
17
|
+
const muiOverlays = {
|
|
18
|
+
TooLarge: TooLargeMessage,
|
|
19
|
+
ErrorBar: DisplayErrorBar,
|
|
20
|
+
Loading: DisplayLoadingOverlay,
|
|
21
|
+
BackgroundProgress: DisplayBackgroundProgress,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The status chrome of a display that owns its canvas: the zoom-in notice in
|
|
26
|
+
* place of the display, and the error bar, loading overlay and progress chip
|
|
27
|
+
* over it in the track's overlay layer. Core's `DisplayStatusChrome` drew this
|
|
28
|
+
* until jbrowse-web main removed it on 2026-10-08 (d8db736896), and its named
|
|
29
|
+
* import came back undefined there, failing the track with React error #130.
|
|
30
|
+
* Built here from the overlays every host from 5.0.0-beta.11 serves.
|
|
31
|
+
*/
|
|
32
|
+
export default function GraphStatusChrome({
|
|
33
|
+
model,
|
|
34
|
+
phase,
|
|
35
|
+
drawn,
|
|
36
|
+
testid,
|
|
37
|
+
style,
|
|
38
|
+
children,
|
|
39
|
+
...dataProps
|
|
40
|
+
}: {
|
|
41
|
+
model: LinearGraphDisplayModel
|
|
42
|
+
phase: DisplayStatusPhase
|
|
43
|
+
drawn: boolean
|
|
44
|
+
testid: string
|
|
45
|
+
style?: CSSProperties
|
|
46
|
+
children: ReactNode
|
|
47
|
+
} & Record<`data-${string}`, unknown>) {
|
|
48
|
+
const { TooLarge, ErrorBar, Loading, BackgroundProgress } =
|
|
49
|
+
useChromeOverlayOverride() ?? muiOverlays
|
|
50
|
+
const [corner, setCorner] = useState<HTMLDivElement | null>(null)
|
|
51
|
+
const { displayId } = model.configuration
|
|
52
|
+
if (phase === 'tooLarge') {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
style={{ display: 'contents' }}
|
|
56
|
+
data-display-id={displayId}
|
|
57
|
+
data-display-phase={phase}
|
|
58
|
+
>
|
|
59
|
+
<TrackOverlayPortal>
|
|
60
|
+
<div style={{ pointerEvents: 'auto' }}>
|
|
61
|
+
<TooLarge model={model} />
|
|
62
|
+
</div>
|
|
63
|
+
</TrackOverlayPortal>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
{...dataProps}
|
|
70
|
+
style={{ position: 'relative', height: model.height, ...style }}
|
|
71
|
+
data-testid={testid}
|
|
72
|
+
data-display-id={displayId}
|
|
73
|
+
data-display-drawn={drawn}
|
|
74
|
+
data-display-phase={phase}
|
|
75
|
+
>
|
|
76
|
+
<BottomRightCornerContext value={corner}>
|
|
77
|
+
{children}
|
|
78
|
+
</BottomRightCornerContext>
|
|
79
|
+
<TrackOverlayPortal>
|
|
80
|
+
<ErrorBar model={model} visible={phase === 'error'} />
|
|
81
|
+
<Loading
|
|
82
|
+
model={model}
|
|
83
|
+
visible={phase === 'loading' || phase === 'canceled'}
|
|
84
|
+
immediate={!drawn}
|
|
85
|
+
/>
|
|
86
|
+
<div
|
|
87
|
+
ref={setCorner}
|
|
88
|
+
style={{
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
bottom: 2,
|
|
91
|
+
right: 2,
|
|
92
|
+
display: 'flex',
|
|
93
|
+
flexDirection: 'column',
|
|
94
|
+
alignItems: 'flex-end',
|
|
95
|
+
gap: 4,
|
|
96
|
+
pointerEvents: 'none',
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<BackgroundProgress model={model} visible={phase === 'ready'} />
|
|
100
|
+
</div>
|
|
101
|
+
</TrackOverlayPortal>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { DisplayStatusChrome } from '@jbrowse/display-kit/DisplayChrome'
|
|
2
1
|
import { observer } from 'mobx-react'
|
|
3
2
|
|
|
3
|
+
import GraphStatusChrome from './GraphStatusChrome'
|
|
4
4
|
import GraphCanvas from '../../GraphGenomeView/components/GraphCanvas'
|
|
5
5
|
|
|
6
6
|
import type { LinearGraphDisplayModel } from '../model'
|
|
@@ -11,7 +11,7 @@ const LinearGraphDisplay = observer(function LinearGraphDisplay({
|
|
|
11
11
|
model: LinearGraphDisplayModel
|
|
12
12
|
}) {
|
|
13
13
|
return (
|
|
14
|
-
<
|
|
14
|
+
<GraphStatusChrome
|
|
15
15
|
model={model}
|
|
16
16
|
phase={model.displayPhase}
|
|
17
17
|
drawn={model.painted && model.geometryPainted}
|
|
@@ -28,7 +28,7 @@ const LinearGraphDisplay = observer(function LinearGraphDisplay({
|
|
|
28
28
|
}}
|
|
29
29
|
>
|
|
30
30
|
<GraphCanvas model={model} ownChrome={false} />
|
|
31
|
-
</
|
|
31
|
+
</GraphStatusChrome>
|
|
32
32
|
)
|
|
33
33
|
})
|
|
34
34
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { gunzipSync } from 'node:zlib'
|
|
3
3
|
|
|
4
|
+
import { TabixIndexedFile } from '@gmod/tabix'
|
|
4
5
|
import { parseGFA } from '@jbrowse/bandage-core/gfa-core/index'
|
|
5
6
|
import { readConfObject } from '@jbrowse/core/configuration'
|
|
6
7
|
import { firstValueFrom } from 'rxjs'
|
|
@@ -243,6 +244,17 @@ const is5 = {
|
|
|
243
244
|
// The lane this exists for: color a linear track by how many haplotypes carry
|
|
244
245
|
// each segment. Before this the tag column reached the graph view's node popup
|
|
245
246
|
// and stopped there, so a feature could say its rank but not its carriage.
|
|
247
|
+
test('getSubgraph refuses a range that is not finite before querying', async () => {
|
|
248
|
+
const getLines = vi
|
|
249
|
+
.spyOn(TabixIndexedFile.prototype, 'getLines')
|
|
250
|
+
.mockResolvedValue()
|
|
251
|
+
await expect(
|
|
252
|
+
makeAdapter().getSubgraph({ ...k12, end: Number.NaN }, { hops: 1 }),
|
|
253
|
+
).rejects.toThrow(/K12#1#chr:993236-NaN is not a finite range/)
|
|
254
|
+
expect(getLines).not.toHaveBeenCalled()
|
|
255
|
+
getLines.mockRestore()
|
|
256
|
+
})
|
|
257
|
+
|
|
246
258
|
test('getFeatures carries SM:Z: onto the feature', async () => {
|
|
247
259
|
const features = await firstValueFrom(
|
|
248
260
|
makePggbAdapter().getFeatures(is5).pipe(toArray()),
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
byteBudgetError,
|
|
23
23
|
chunkQueryStart,
|
|
24
24
|
joinPieces,
|
|
25
|
+
lookbackChunks,
|
|
25
26
|
parseWalkRow,
|
|
26
27
|
stepBudgetError,
|
|
27
28
|
walkCut,
|
|
@@ -77,6 +78,28 @@ function offReference(segments: RgfaSegment[]) {
|
|
|
77
78
|
return segments.filter(segment => segment.rank > 0)
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// tabix-js loops forever on a NaN end and reads nothing for a NaN start
|
|
82
|
+
// (GMOD/tabix-js#157), so every query the adapter makes refuses a range that
|
|
83
|
+
// is not finite first
|
|
84
|
+
function checkRange(refName: string, start: number, end: number) {
|
|
85
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${refName}:${start}-${end} is not a finite range to query the graph's index for`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function getLines(
|
|
93
|
+
file: TabixIndexedFile,
|
|
94
|
+
refName: string,
|
|
95
|
+
start: number,
|
|
96
|
+
end: number,
|
|
97
|
+
opts: { signal?: AbortSignal; lineCallback: (line: string) => void },
|
|
98
|
+
) {
|
|
99
|
+
checkRange(refName, start, end)
|
|
100
|
+
return file.getLines(refName, start, end, opts)
|
|
101
|
+
}
|
|
102
|
+
|
|
80
103
|
interface GraphIndex {
|
|
81
104
|
segments: TabixIndexedFile
|
|
82
105
|
links: TabixIndexedFile
|
|
@@ -130,18 +153,25 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
130
153
|
}
|
|
131
154
|
|
|
132
155
|
// A walk-indexed graph's header: the chunk its rows are filed under, else
|
|
133
|
-
// the walkChunk slot,
|
|
156
|
+
// the walkChunk slot, how many chunks before a window a cut reads, and the
|
|
157
|
+
// haplotypes it names
|
|
134
158
|
private walkHeader = cachedSetup({
|
|
135
159
|
setup: async opts => {
|
|
136
160
|
const walks = this.fine.walks
|
|
137
161
|
const header = walkHeader(walks ? await walks.getHeaderLines(opts) : [])
|
|
138
162
|
const slot: number = this.getConf('walkChunk')
|
|
139
|
-
|
|
163
|
+
const chunk = header.chunk ?? slot
|
|
164
|
+
return {
|
|
165
|
+
...header,
|
|
166
|
+
chunk,
|
|
167
|
+
lookback: lookbackChunks(header.maxNode, chunk),
|
|
168
|
+
}
|
|
140
169
|
},
|
|
141
170
|
})
|
|
142
171
|
|
|
143
|
-
private async
|
|
144
|
-
|
|
172
|
+
private async walkQueryStart(start: number, opts: BaseOptions) {
|
|
173
|
+
const { chunk, lookback } = await this.walkHeader(opts)
|
|
174
|
+
return chunkQueryStart(start, chunk, lookback)
|
|
145
175
|
}
|
|
146
176
|
|
|
147
177
|
/**
|
|
@@ -178,12 +208,12 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
178
208
|
const tabixRefName = await this.fine.refNames.resolve(query, opts)
|
|
179
209
|
if (tabixRefName !== undefined) {
|
|
180
210
|
// a walk-indexed node row names only its chunk's first base, and a
|
|
181
|
-
// node crossing in from
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
// node crossing in from a chunk before is filed there
|
|
212
|
+
const start = this.fine.walks
|
|
213
|
+
? await this.walkQueryStart(query.start, opts)
|
|
214
|
+
: query.start
|
|
185
215
|
await updateStatus('Downloading segments', statusCallback, () =>
|
|
186
|
-
this.fine.segments
|
|
216
|
+
getLines(this.fine.segments, tabixRefName, start, query.end, {
|
|
187
217
|
signal,
|
|
188
218
|
lineCallback: line => {
|
|
189
219
|
const { segment, anchored } = parseSegmentRow(line)
|
|
@@ -246,7 +276,7 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
246
276
|
end: number,
|
|
247
277
|
) => {
|
|
248
278
|
const reached: RgfaSegment[] = []
|
|
249
|
-
await index.links
|
|
279
|
+
await getLines(index.links, refName, start, end, {
|
|
250
280
|
signal,
|
|
251
281
|
lineCallback: line => {
|
|
252
282
|
const link = parseLinkLine(line)
|
|
@@ -271,7 +301,7 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
271
301
|
return this.walkSubgraph(index, index.walks, tabixRefName, region, opts)
|
|
272
302
|
}
|
|
273
303
|
const layout = { anchored: false }
|
|
274
|
-
await index.segments
|
|
304
|
+
await getLines(index.segments, tabixRefName, region.start, region.end, {
|
|
275
305
|
signal,
|
|
276
306
|
lineCallback: line => {
|
|
277
307
|
const row = parseSegmentRow(line)
|
|
@@ -284,7 +314,7 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
284
314
|
// graph under the region and there is nothing to hop to or close.
|
|
285
315
|
if (layout.anchored) {
|
|
286
316
|
const found: RgfaLink[] = []
|
|
287
|
-
await index.links
|
|
317
|
+
await getLines(index.links, tabixRefName, region.start, region.end, {
|
|
288
318
|
signal,
|
|
289
319
|
lineCallback: line => {
|
|
290
320
|
found.push(parseLinkLine(line))
|
|
@@ -322,7 +352,7 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
322
352
|
const found: RgfaLink[] = []
|
|
323
353
|
await Promise.all(
|
|
324
354
|
spans.map(span =>
|
|
325
|
-
index.links
|
|
355
|
+
getLines(index.links, span.refName, span.start, span.end, {
|
|
326
356
|
signal,
|
|
327
357
|
lineCallback: line => {
|
|
328
358
|
found.push(parseLinkLine(line))
|
|
@@ -344,11 +374,11 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
344
374
|
}
|
|
345
375
|
|
|
346
376
|
// A walk-indexed graph's cut: one read of each file over whole chunks, from
|
|
347
|
-
// the
|
|
348
|
-
// bytes those reads fetch, and a window past walkByteBudget
|
|
349
|
-
// Only the walks `haplotypes` asks for, and the reference's,
|
|
350
|
-
// and none is past walkStepBudget: the three reads go out
|
|
351
|
-
// walk read over budget stops the other two.
|
|
377
|
+
// the lookback chunks before the window to its end. The indexes first
|
|
378
|
+
// estimate the bytes those reads fetch, and a window past walkByteBudget
|
|
379
|
+
// reads no row. Only the walks `haplotypes` asks for, and the reference's,
|
|
380
|
+
// are decoded, and none is past walkStepBudget: the three reads go out
|
|
381
|
+
// together and a walk read over budget stops the other two.
|
|
352
382
|
private async walkSubgraph(
|
|
353
383
|
index: GraphIndex,
|
|
354
384
|
walks: TabixIndexedFile,
|
|
@@ -357,16 +387,17 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
357
387
|
opts: SubgraphAdapterOptions,
|
|
358
388
|
) {
|
|
359
389
|
const { signal, haplotypes } = opts
|
|
360
|
-
const chunk = await this.
|
|
390
|
+
const { chunk, lookback } = await this.walkHeader({ signal })
|
|
361
391
|
const budget: number = this.getConf('walkStepBudget')
|
|
362
392
|
const keep = walkNameFilter(
|
|
363
393
|
haplotypes?.map(lane => resolvePanSNPrefix(this, lane)),
|
|
364
394
|
panSNHaplotype(refName),
|
|
365
395
|
)
|
|
366
|
-
const from = chunkQueryStart(region.start, chunk)
|
|
396
|
+
const from = chunkQueryStart(region.start, chunk, lookback)
|
|
367
397
|
const files = [walks, index.segments, index.links]
|
|
368
398
|
const tooLarge = await byteBudgetError(
|
|
369
399
|
async end => {
|
|
400
|
+
checkRange(refName, from, end)
|
|
370
401
|
const sizes = await Promise.all(
|
|
371
402
|
files.map(file =>
|
|
372
403
|
file.bytesForRegions([{ refName, start: from, end }], { signal }),
|
|
@@ -397,7 +428,7 @@ export default class RgfaTabixAdapter extends BaseFeatureDataAdapter<RgfaTabixAd
|
|
|
397
428
|
file: TabixIndexedFile,
|
|
398
429
|
lineCallback: (line: string) => void,
|
|
399
430
|
) =>
|
|
400
|
-
|
|
431
|
+
getLines(file, refName, from, region.end, {
|
|
401
432
|
signal: reads.signal,
|
|
402
433
|
lineCallback,
|
|
403
434
|
})
|
|
@@ -19,11 +19,13 @@ import type { Instance } from '@jbrowse/mobx-state-tree'
|
|
|
19
19
|
* `build_bubble_tier.sh` in jbrowse-components, plus the zoom past which a
|
|
20
20
|
* graph view following a linear view cuts it.
|
|
21
21
|
*
|
|
22
|
-
* `walksUri` takes the prefix
|
|
23
|
-
*
|
|
24
|
-
* `<
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* `walksUri` takes the prefix of a walk-indexed file set in place of `uri`, and
|
|
23
|
+
* resolves `<walksUri>.walks.bed.gz`, `<walksUri>.nodes.bed.gz`,
|
|
24
|
+
* `<walksUri>.links.bed.gz` and their `.tbi` indexes. `gfa-to-tabix --walks -o
|
|
25
|
+
* <prefix>` writes a set per reference sample from 0.5.0, so a track names one
|
|
26
|
+
* as `<prefix>.<sample>`; a set from 0.4.0 holds every reference, and the track
|
|
27
|
+
* names `<prefix>`. Such a graph's cut carries the haplotypes' walks, and
|
|
28
|
+
* `defaultHaplotypes` names the ones it is cut for until the user picks others.
|
|
27
29
|
*
|
|
28
30
|
* #example
|
|
29
31
|
* ```js
|
|
@@ -38,8 +40,8 @@ import type { Instance } from '@jbrowse/mobx-state-tree'
|
|
|
38
40
|
* ```js
|
|
39
41
|
* {
|
|
40
42
|
* type: 'RgfaTabixAdapter',
|
|
41
|
-
* walksUri: 'https://
|
|
42
|
-
* assemblyNameToPanSN: { hg38: 'GRCh38'
|
|
43
|
+
* walksUri: 'https://jbrowse.org/demos/hprc/hprc-v2.1-mc-grch38.GRCh38',
|
|
44
|
+
* assemblyNameToPanSN: { hg38: 'GRCh38' },
|
|
43
45
|
* defaultHaplotypes: ['HG002', 'HG00733', 'HG02257', 'NA19240'],
|
|
44
46
|
* }
|
|
45
47
|
* ```
|
|
@@ -294,9 +296,10 @@ const RgfaTabixAdapter = ConfigurationSchema(
|
|
|
294
296
|
* ```json
|
|
295
297
|
* { "type": "RgfaTabixAdapter", "uri": "graph.rgfa" }
|
|
296
298
|
* ```
|
|
297
|
-
* or, for a graph built by `gfa-to-tabix --walks -o
|
|
299
|
+
* or, for the GRCh38 set of a graph built by `gfa-to-tabix --walks -o
|
|
300
|
+
* chr22`,
|
|
298
301
|
* ```json
|
|
299
|
-
* { "type": "RgfaTabixAdapter", "walksUri": "chr22" }
|
|
302
|
+
* { "type": "RgfaTabixAdapter", "walksUri": "chr22.GRCh38" }
|
|
300
303
|
* ```
|
|
301
304
|
*/
|
|
302
305
|
preProcessSnapshot: normalizeSnapshot,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -34,6 +34,14 @@ const fixturePrefix = require
|
|
|
34
34
|
.resolve('./test_data/walks_header.walks.bed.gz')
|
|
35
35
|
.replace(/\.walks\.bed\.gz$/, '')
|
|
36
36
|
|
|
37
|
+
// An unchopped graph, built by gfa-to-tabix --walks --refs GRCh38 --chunk 1000
|
|
38
|
+
// (0.4.0), with the first header line 0.5.0 writes put in by hand:
|
|
39
|
+
// W GRCh38 0 chr1 >1>2>4 node 1 spans 0-2600, filed under chunk 0 alone
|
|
40
|
+
// W HG002 1 chr1 >1>3>4 3 replaces 2
|
|
41
|
+
const maxNodePrefix = require
|
|
42
|
+
.resolve('./test_data/walks_maxnode.walks.bed.gz')
|
|
43
|
+
.replace(/\.walks\.bed\.gz$/, '')
|
|
44
|
+
|
|
37
45
|
function makeAdapter(prefix = spikePrefix, slots = {}) {
|
|
38
46
|
const local = (path: string) => ({
|
|
39
47
|
localPath: path,
|
|
@@ -273,3 +281,85 @@ test.skipIf(!rustPresent)(
|
|
|
273
281
|
})
|
|
274
282
|
},
|
|
275
283
|
)
|
|
284
|
+
|
|
285
|
+
test.skipIf(!rustPresent)(
|
|
286
|
+
'a cut for every haplotype past the step budget offers fewer haplotypes',
|
|
287
|
+
async () => {
|
|
288
|
+
const cut = makeAdapter(rustPrefix).getSubgraph({
|
|
289
|
+
...window,
|
|
290
|
+
end: 20_260_000,
|
|
291
|
+
})
|
|
292
|
+
await expect(cut).rejects.toMatchObject({
|
|
293
|
+
name: 'NodeLimitError',
|
|
294
|
+
message: expect.stringMatching(
|
|
295
|
+
/^Zoom in to about \d+Kbp, or choose fewer haplotypes, to see the graph$/,
|
|
296
|
+
),
|
|
297
|
+
})
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
// tabix-js's getLines spins forever on a NaN end, so these mock it: a
|
|
302
|
+
// regression fails here instead of hanging the suite
|
|
303
|
+
test('a cut or a feature read over a range that is not finite queries nothing', async () => {
|
|
304
|
+
const getLines = vi
|
|
305
|
+
.spyOn(TabixIndexedFile.prototype, 'getLines')
|
|
306
|
+
.mockResolvedValue()
|
|
307
|
+
const bytes = vi
|
|
308
|
+
.spyOn(TabixIndexedFile.prototype, 'bytesForRegions')
|
|
309
|
+
.mockResolvedValue(0)
|
|
310
|
+
const adapter = makeAdapter(fixturePrefix, {
|
|
311
|
+
assemblyNameToPanSN: { hg38: 'GRCh38' },
|
|
312
|
+
})
|
|
313
|
+
const region = {
|
|
314
|
+
refName: 'chr1',
|
|
315
|
+
assemblyName: 'hg38',
|
|
316
|
+
start: Number.NaN,
|
|
317
|
+
end: 2600,
|
|
318
|
+
}
|
|
319
|
+
await expect(adapter.getSubgraph(region)).rejects.toThrow(
|
|
320
|
+
"GRCh38#0#chr1:NaN-2600 is not a finite range to query the graph's index for",
|
|
321
|
+
)
|
|
322
|
+
await expect(
|
|
323
|
+
firstValueFrom(adapter.getFeatures(region).pipe(toArray())),
|
|
324
|
+
).rejects.toThrow(/not a finite range/)
|
|
325
|
+
expect(getLines).not.toHaveBeenCalled()
|
|
326
|
+
expect(bytes).not.toHaveBeenCalled()
|
|
327
|
+
getLines.mockRestore()
|
|
328
|
+
bytes.mockRestore()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test("a node longer than a chunk is read back as far as the header's maxnode reaches", async () => {
|
|
332
|
+
const adapter = makeAdapter(maxNodePrefix, {
|
|
333
|
+
assemblyNameToPanSN: { hg38: 'GRCh38' },
|
|
334
|
+
})
|
|
335
|
+
const region = {
|
|
336
|
+
refName: 'chr1',
|
|
337
|
+
assemblyName: 'hg38',
|
|
338
|
+
start: 2100,
|
|
339
|
+
end: 2500,
|
|
340
|
+
}
|
|
341
|
+
const features = await firstValueFrom(
|
|
342
|
+
adapter.getFeatures(region).pipe(toArray()),
|
|
343
|
+
)
|
|
344
|
+
expect(features.map(f => f.get('name'))).toContain('1')
|
|
345
|
+
const gfa = await adapter.getSubgraph(region)
|
|
346
|
+
expect(gfa).toMatch(/^S\t1\t/m)
|
|
347
|
+
expect(gfa).toMatch(/^W\tGRCh38\t0\tchr1\t0\t3200\t>1>2$/m)
|
|
348
|
+
expect(walkNames(gfa)).toEqual(['GRCh38#0#chr1', 'HG002#1#chr1'])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('a header without maxnode reads from the chunk before the window', async () => {
|
|
352
|
+
const getLines = vi.spyOn(TabixIndexedFile.prototype, 'getLines')
|
|
353
|
+
await makeAdapter(fixturePrefix, {
|
|
354
|
+
assemblyNameToPanSN: { hg38: 'GRCh38' },
|
|
355
|
+
}).getSubgraph({
|
|
356
|
+
refName: 'chr1',
|
|
357
|
+
assemblyName: 'hg38',
|
|
358
|
+
start: 2100,
|
|
359
|
+
end: 2600,
|
|
360
|
+
})
|
|
361
|
+
expect(new Set(getLines.mock.calls.map(call => call[1]))).toEqual(
|
|
362
|
+
new Set([1000]),
|
|
363
|
+
)
|
|
364
|
+
getLines.mockRestore()
|
|
365
|
+
})
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
byteBudgetError,
|
|
4
4
|
chunkQueryStart,
|
|
5
5
|
joinPieces,
|
|
6
|
+
lookbackChunks,
|
|
6
7
|
parseWalkRow,
|
|
7
8
|
stepBudgetError,
|
|
8
9
|
walkCut,
|
|
@@ -139,6 +140,29 @@ describe('the step budget', () => {
|
|
|
139
140
|
expect(error.message).toBe('Zoom in to about 150bp to see the graph')
|
|
140
141
|
})
|
|
141
142
|
|
|
143
|
+
test('offers a cut for every haplotype fewer as well as a zoom', () => {
|
|
144
|
+
// four walks, the reference's among them, with these steps each per chunk
|
|
145
|
+
const each: [number, number][] = [
|
|
146
|
+
[100, 1],
|
|
147
|
+
[200, 1],
|
|
148
|
+
[300, 2],
|
|
149
|
+
[400, 1],
|
|
150
|
+
]
|
|
151
|
+
const four = [
|
|
152
|
+
'GRCh38#0#chr1',
|
|
153
|
+
'HG002#1#chr1',
|
|
154
|
+
'HG002#2#chr1',
|
|
155
|
+
'NA19240#1#chr1',
|
|
156
|
+
].flatMap(name => rows(each).map(row => ({ ...row, name })))
|
|
157
|
+
const window = { start: 250, end: 480 }
|
|
158
|
+
expect(stepBudgetError(four, 10, window, chunk, false)!.message).toBe(
|
|
159
|
+
'Zoom in to about 50bp, or choose fewer haplotypes, to see the graph',
|
|
160
|
+
)
|
|
161
|
+
expect(stepBudgetError(four, 10, window, chunk, true)!.message).toBe(
|
|
162
|
+
'Zoom in to about 50bp to see the graph',
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
142
166
|
test('asks for fewer haplotypes where no zoom fits', () => {
|
|
143
167
|
const error = stepBudgetError(
|
|
144
168
|
rows([
|
|
@@ -184,6 +208,35 @@ test("a walk file's header states its chunk, references and haplotypes", () => {
|
|
|
184
208
|
})
|
|
185
209
|
})
|
|
186
210
|
|
|
211
|
+
test("a 0.5.0 header's first line gives the longest node beside the chunk", () => {
|
|
212
|
+
expect(
|
|
213
|
+
walkHeader([
|
|
214
|
+
'#walks\tchunk:i:65536\tmaxnode:i:1024\tcap:i:8192',
|
|
215
|
+
'#reference\tGRCh38',
|
|
216
|
+
]),
|
|
217
|
+
).toEqual({
|
|
218
|
+
chunk: 65_536,
|
|
219
|
+
maxNode: 1024,
|
|
220
|
+
references: ['GRCh38'],
|
|
221
|
+
haplotypes: [],
|
|
222
|
+
})
|
|
223
|
+
expect(walkHeader(['#walks\tmaxnode:i:300000\tchunk:i:1000'])).toMatchObject({
|
|
224
|
+
chunk: 1000,
|
|
225
|
+
maxNode: 300_000,
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('a cut looks back as many chunks as the longest node spans', () => {
|
|
230
|
+
expect(lookbackChunks(undefined, 65_536)).toBe(1)
|
|
231
|
+
expect(lookbackChunks(0, 65_536)).toBe(1)
|
|
232
|
+
expect(lookbackChunks(1024, 65_536)).toBe(1)
|
|
233
|
+
expect(lookbackChunks(65_536, 65_536)).toBe(1)
|
|
234
|
+
expect(lookbackChunks(65_537, 65_536)).toBe(2)
|
|
235
|
+
expect(lookbackChunks(300_000, 65_536)).toBe(5)
|
|
236
|
+
expect(chunkQueryStart(20_000_000, 65_536, 5)).toBe(19_660_800)
|
|
237
|
+
expect(chunkQueryStart(200_000, 65_536, 5)).toBe(0)
|
|
238
|
+
})
|
|
239
|
+
|
|
187
240
|
describe('the byte budget', () => {
|
|
188
241
|
const chunk = 100
|
|
189
242
|
// bytes a read from the query start to `end` fetches, 10 per chunk row
|