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.
Files changed (27) hide show
  1. package/dist/chunks/LinearGraphDisplay-ZJNR2KCT.js +2 -0
  2. package/dist/chunks/LinearGraphDisplay-ZJNR2KCT.js.map +7 -0
  3. package/dist/chunks/RgfaTabixAdapter-KOIMYOR3.js +4 -0
  4. package/dist/chunks/RgfaTabixAdapter-KOIMYOR3.js.map +7 -0
  5. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +1 -1
  6. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +2 -2
  7. package/package.json +2 -1
  8. package/src/LinearGraphDisplay/components/GraphStatusChrome.test.tsx +78 -0
  9. package/src/LinearGraphDisplay/components/GraphStatusChrome.tsx +104 -0
  10. package/src/LinearGraphDisplay/components/LinearGraphDisplay.tsx +3 -3
  11. package/src/RgfaTabixAdapter/RgfaTabixAdapter.test.ts +12 -0
  12. package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +52 -21
  13. package/src/RgfaTabixAdapter/configSchema.ts +12 -9
  14. package/src/RgfaTabixAdapter/test_data/walks_maxnode.links.bed.gz +0 -0
  15. package/src/RgfaTabixAdapter/test_data/walks_maxnode.links.bed.gz.tbi +0 -0
  16. package/src/RgfaTabixAdapter/test_data/walks_maxnode.nodes.bed.gz +0 -0
  17. package/src/RgfaTabixAdapter/test_data/walks_maxnode.nodes.bed.gz.tbi +0 -0
  18. package/src/RgfaTabixAdapter/test_data/walks_maxnode.walks.bed.gz +0 -0
  19. package/src/RgfaTabixAdapter/test_data/walks_maxnode.walks.bed.gz.tbi +0 -0
  20. package/src/RgfaTabixAdapter/walkFiles.test.ts +90 -0
  21. package/src/RgfaTabixAdapter/walkRows.test.ts +53 -0
  22. package/src/RgfaTabixAdapter/walkRows.ts +38 -16
  23. package/src/version.ts +1 -1
  24. package/dist/chunks/LinearGraphDisplay-HOS4F4V6.js +0 -2
  25. package/dist/chunks/LinearGraphDisplay-HOS4F4V6.js.map +0 -7
  26. package/dist/chunks/RgfaTabixAdapter-JUQ64AJS.js +0 -4
  27. 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.0",
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
- <DisplayStatusChrome
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
- </DisplayStatusChrome>
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, and the haplotypes it names
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
- return { ...header, chunk: header.chunk ?? slot }
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 walkChunk(opts: BaseOptions) {
144
- return (await this.walkHeader(opts)).chunk
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 the chunk before is filed there
182
- const chunk = this.fine.walks ? await this.walkChunk(opts) : 0
183
- const start =
184
- chunk > 0 ? chunkQueryStart(query.start, chunk) : query.start
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.getLines(tabixRefName, start, query.end, {
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.getLines(refName, start, end, {
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.getLines(tabixRefName, region.start, region.end, {
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.getLines(tabixRefName, region.start, region.end, {
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.getLines(span.refName, span.start, span.end, {
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 chunk before the window to its end. The indexes first estimate the
348
- // bytes those reads fetch, and a window past walkByteBudget reads no row.
349
- // Only the walks `haplotypes` asks for, and the reference's, are decoded,
350
- // and none is past walkStepBudget: the three reads go out together and a
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.walkChunk({ signal })
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
- file.getLines(refName, from, region.end, {
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 `gfa-to-tabix --walks -o <prefix>` was given, in
23
- * place of `uri`, and resolves `<prefix>.walks.bed.gz`, `<prefix>.nodes.bed.gz`,
24
- * `<prefix>.links.bed.gz` and their `.tbi` indexes. Such a graph's cut carries
25
- * the haplotypes' walks, and `defaultHaplotypes` names the ones it is cut for
26
- * until the user picks others.
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://example.com/hprc-v2.1.chr22',
42
- * assemblyNameToPanSN: { hg38: 'GRCh38', hs1: 'CHM13' },
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 chr22`,
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,
@@ -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