@jbrowse/graph-walks 0.0.0-stage → 1.0.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.
@@ -0,0 +1,480 @@
1
+ import { RegionTooLargeError, bpString, panSNMatchesPrefix } from "./util.js";
2
+ import { placeAndLink } from "./walkChunk.js";
3
+ function integerTag(line, tag) {
4
+ const match = new RegExp(`(?:^|\\t)${tag}:i:(\\d+)`).exec(line);
5
+ return match ? +match[1] : undefined;
6
+ }
7
+ /**
8
+ * What a walk file's header states: a `#walks` line with the format, the
9
+ * chunk size and the longest node in bp, `format:i:2\tchunk:i:65536\tmaxnode:i:1024`,
10
+ * a `#reference\tGRCh38` line naming the reference sample and a
11
+ * `#haplotype\tHG002#1` line per other haplotype with rows
12
+ */
13
+ export function walkHeader(lines) {
14
+ var _a, _b, _c;
15
+ const header = { references: [], haplotypes: [] };
16
+ for (const line of lines) {
17
+ const [key, value = ''] = line.split('\t');
18
+ if (key === '#reference') {
19
+ header.references.push(value);
20
+ }
21
+ else if (key === '#haplotype') {
22
+ header.haplotypes.push(value);
23
+ }
24
+ else {
25
+ (_a = header.format) !== null && _a !== void 0 ? _a : (header.format = integerTag(line, 'format'));
26
+ (_b = header.chunk) !== null && _b !== void 0 ? _b : (header.chunk = integerTag(line, 'chunk'));
27
+ (_c = header.maxNode) !== null && _c !== void 0 ? _c : (header.maxNode = integerTag(line, 'maxnode'));
28
+ }
29
+ }
30
+ return header;
31
+ }
32
+ /**
33
+ * How many chunks before the window's own a cut reads. A node is filed under
34
+ * the chunk holding its start, so a node `maxNode` bp long reaching into the
35
+ * window starts at most ceil(maxNode / chunk) chunks back. At least one, since
36
+ * the chunk before carries the detour a haplotype takes into the window's
37
+ * first reference node; one when the header gives no `maxnode:i:`, which holds
38
+ * for vg's graphs, chopped to nodes of at most 1,024 bp.
39
+ */
40
+ export function lookbackChunks(maxNode, chunk) {
41
+ return maxNode === undefined ? 1 : Math.max(1, Math.ceil(maxNode / chunk));
42
+ }
43
+ // The first base a cut queries: the start of the chunk `lookback` chunks
44
+ // before the one holding the window's start
45
+ export function chunkQueryStart(start, chunk, lookback = 1) {
46
+ return Math.max(0, (Math.floor(start / chunk) - lookback) * chunk);
47
+ }
48
+ // Which walks a cut decodes: the reference's, and those `wanted` names by
49
+ // PanSN prefix at sample or haplotype depth; undefined decodes every one
50
+ export function walkNameFilter(wanted, referenceHaplotype) {
51
+ return wanted === undefined || wanted.length === 0
52
+ ? undefined
53
+ : (name) => panSNMatchesPrefix(name, referenceHaplotype) ||
54
+ wanted.some(prefix => panSNMatchesPrefix(name, prefix));
55
+ }
56
+ export function joinPieces(rows) {
57
+ const byPath = new Map();
58
+ for (const row of rows) {
59
+ const key = `${row.name}\t${row.fragStart}`;
60
+ const list = byPath.get(key);
61
+ if (list) {
62
+ list.push(row);
63
+ }
64
+ else {
65
+ byPath.set(key, [row]);
66
+ }
67
+ }
68
+ const runs = [];
69
+ for (const pieces of byPath.values()) {
70
+ pieces.sort((a, b) => a.piece - b.piece);
71
+ let from = 0;
72
+ for (let i = 1; i <= pieces.length; i++) {
73
+ if (i === pieces.length ||
74
+ pieces[i].piece !== pieces[i - 1].piece + 1) {
75
+ runs.push(decodeRun(pieces.slice(from, i)));
76
+ from = i;
77
+ }
78
+ }
79
+ }
80
+ return runs;
81
+ }
82
+ function decodeRun(pieces) {
83
+ const n = pieces.reduce((sum, p) => sum + p.n, 0);
84
+ const ids = new Int32Array(n);
85
+ const rev = new Uint8Array(n);
86
+ let at = 0;
87
+ for (const p of pieces) {
88
+ ids.set(p.steps.ids, at);
89
+ rev.set(p.steps.rev, at);
90
+ at += p.n;
91
+ }
92
+ const first = pieces[0];
93
+ return {
94
+ name: first.name,
95
+ hapOffset: first.hapOffset,
96
+ ids,
97
+ rev,
98
+ prev: first.prev,
99
+ next: pieces.at(-1).next,
100
+ };
101
+ }
102
+ /**
103
+ * The cut a window draws from its walks: the reference inside the window plus
104
+ * its context, and each run from its first to its last step on that stretch.
105
+ * A run that leaves the reference there, through steps off it or across a
106
+ * deletion, is followed to the reference step where it rejoins, when the read
107
+ * holds it, and otherwise through the steps off the reference it holds: at
108
+ * the ABCA7 VNTR four haplotypes leave inside the repeat and rejoin 1.8 kb
109
+ * past the window, and cut at the context they measured as partial walks. The reference is cut out as far as those rejoins reach, and
110
+ * a run then runs on outward while the next step's node is already in the
111
+ * cut. Without the run-on a haplotype whose alternate allele straddles the
112
+ * edge stopped short of a node other walks had brought into the cut, and that
113
+ * node drew without it. The run-on adds no node, so one pass is enough, but
114
+ * it reaches through nodes other walks brought in, so with a window edge inside
115
+ * a collapsed array a row's length depends on which haplotypes are cut.
116
+ */
117
+ export function walkCut(runs, nodes, refName) {
118
+ const onWindow = (id) => { var _a; return ((_a = nodes.get(id)) === null || _a === void 0 ? void 0 : _a.onWindow) === true; };
119
+ const reference = (id) => {
120
+ const node = nodes.get(id);
121
+ return (node === null || node === void 0 ? void 0 : node.rank) === 0 ? node : undefined;
122
+ };
123
+ // the chunks walks cut off at the end of a run go on in
124
+ const open = new Map();
125
+ const goesOn = (chunk) => {
126
+ if (chunk) {
127
+ open.set(`${chunk.refName}:${chunk.start}`, chunk);
128
+ }
129
+ };
130
+ // Where a walk stops being followed from step `at`, going by `dir`: the
131
+ // reference step it rejoins at past any steps off the reference, or across
132
+ // a deletion; else its last step off the reference before the run ends,
133
+ // noting the chunk the path goes on in, or reaches another reference
134
+ // sequence
135
+ const rejoin = (run, at, dir) => {
136
+ const { ids } = run;
137
+ let i = at + dir;
138
+ while (i >= 0 && i < ids.length && reference(ids[i]) === undefined) {
139
+ i += dir;
140
+ }
141
+ if (i < 0 || i >= ids.length) {
142
+ goesOn(dir < 0 ? run.prev : run.next);
143
+ return i - dir;
144
+ }
145
+ const from = reference(ids[at]);
146
+ const to = reference(ids[i]);
147
+ if (to.refName !== refName) {
148
+ return i - dir;
149
+ }
150
+ const adjacent = i === at + dir && (to.start === from.end || to.end === from.start);
151
+ return adjacent ? at : i;
152
+ };
153
+ let reachStart = Infinity;
154
+ let reachEnd = -Infinity;
155
+ const spans = [];
156
+ for (const run of runs) {
157
+ const { ids } = run;
158
+ let first = -1;
159
+ let last = -1;
160
+ for (let i = 0; i < ids.length; i++) {
161
+ if (onWindow(ids[i])) {
162
+ if (first < 0) {
163
+ first = i;
164
+ }
165
+ last = i;
166
+ }
167
+ }
168
+ if (first >= 0) {
169
+ if (run.name !== refName) {
170
+ first = rejoin(run, first, -1);
171
+ last = rejoin(run, last, 1);
172
+ for (const i of [first, last]) {
173
+ const node = reference(ids[i]);
174
+ if (node) {
175
+ reachStart = Math.min(reachStart, node.start);
176
+ reachEnd = Math.max(reachEnd, node.end);
177
+ }
178
+ }
179
+ }
180
+ spans.push({ run, first, last });
181
+ }
182
+ }
183
+ const inReach = (id) => {
184
+ const node = reference(id);
185
+ return (onWindow(id) ||
186
+ ((node === null || node === void 0 ? void 0 : node.refName) === refName &&
187
+ node.end > reachStart &&
188
+ node.start < reachEnd));
189
+ };
190
+ const kept = new Set();
191
+ for (const [id, node] of nodes) {
192
+ if (node.onWindow) {
193
+ kept.add(id);
194
+ }
195
+ }
196
+ for (const span of spans) {
197
+ const { ids } = span.run;
198
+ if (span.run.name === refName) {
199
+ while (span.first > 0 && inReach(ids[span.first - 1])) {
200
+ span.first--;
201
+ }
202
+ while (span.last < ids.length - 1 && inReach(ids[span.last + 1])) {
203
+ span.last++;
204
+ }
205
+ }
206
+ for (let i = span.first; i <= span.last; i++) {
207
+ kept.add(ids[i]);
208
+ }
209
+ }
210
+ const length = (id) => {
211
+ const node = nodes.get(id);
212
+ return node === undefined ? 0 : node.end - node.start;
213
+ };
214
+ const fragments = [];
215
+ for (const { run, first: f, last: l } of spans) {
216
+ const { ids, rev } = run;
217
+ let first = f;
218
+ let last = l;
219
+ while (first > 0 && kept.has(ids[first - 1])) {
220
+ first--;
221
+ }
222
+ while (last < ids.length - 1 && kept.has(ids[last + 1])) {
223
+ last++;
224
+ }
225
+ let hapStart = run.hapOffset;
226
+ for (let i = 0; i < first; i++) {
227
+ hapStart += length(ids[i]);
228
+ }
229
+ let hapEnd = hapStart;
230
+ for (let i = first; i <= last; i++) {
231
+ hapEnd += length(ids[i]);
232
+ }
233
+ fragments.push({
234
+ name: run.name,
235
+ hapStart,
236
+ hapEnd,
237
+ ids: ids.subarray(first, last + 1),
238
+ rev: rev.subarray(first, last + 1),
239
+ });
240
+ }
241
+ return { kept, fragments, open: [...open.values()] };
242
+ }
243
+ /**
244
+ * The nodes and links of the chunks a cut reads, and the tables of the cut
245
+ * they make with its walks. A node in two chunks is placed once.
246
+ */
247
+ export class WalkGraph {
248
+ constructor(window) {
249
+ this.nodes = new Map();
250
+ this.sequences = new Map();
251
+ this.links = [];
252
+ // the chunks read, whose nodes off R and links tables() fills in
253
+ this.chunks = [];
254
+ this.window = window;
255
+ }
256
+ // A node with its position, unless the cut already holds it
257
+ placeNode(id, node) {
258
+ if (this.nodes.has(id)) {
259
+ return;
260
+ }
261
+ const { window } = this;
262
+ this.nodes.set(id, {
263
+ ...node,
264
+ onWindow: node.rank === 0 &&
265
+ node.refName === window.refName &&
266
+ node.end > window.start - window.context &&
267
+ node.start < window.end + window.context,
268
+ });
269
+ }
270
+ // A link between two endpoints, each id * 2 + 1 when reversed
271
+ addLinkEnds(source, target) {
272
+ this.links.push(source, target);
273
+ }
274
+ addChunk(chunk) {
275
+ this.chunks.push(chunk);
276
+ }
277
+ // Bases of nodes a chunk's rows give, for a window that asks for them
278
+ addSequences(sequences) {
279
+ if (this.window.sequences) {
280
+ for (const [id, bases] of sequences) {
281
+ this.sequences.set(id, bases);
282
+ }
283
+ }
284
+ }
285
+ /**
286
+ * The cut as typed arrays, which the worker transfers rather than copies:
287
+ * nodes by reference position, each link once in the order its L line
288
+ * sorts, then a walk per fragment, the reference's first and the rest by
289
+ * name. graphTablesGFA writes them as the GFA they stand for, the same bytes
290
+ * for the same rows.
291
+ */
292
+ tables(kept, fragments) {
293
+ if (this.chunks.length > 0) {
294
+ placeAndLink(this.chunks, kept, (id, node) => {
295
+ const held = this.nodes.get(id);
296
+ if (held) {
297
+ this.nodes.set(id, { ...held, ...node });
298
+ }
299
+ }, (a, b) => {
300
+ this.links.push(a, b);
301
+ });
302
+ }
303
+ const held = [];
304
+ for (const id of kept) {
305
+ const node = this.nodes.get(id);
306
+ if (node) {
307
+ held.push({ ...node, id });
308
+ }
309
+ }
310
+ held.sort((x, y) => x.refName < y.refName
311
+ ? -1
312
+ : x.refName > y.refName
313
+ ? 1
314
+ : x.start - y.start || x.id - y.id);
315
+ const index = new Map();
316
+ const names = [];
317
+ const refNames = [];
318
+ const refIndex = new Map();
319
+ const lengths = new Int32Array(held.length);
320
+ const refs = new Int32Array(held.length);
321
+ const starts = new Float64Array(held.length);
322
+ const ranks = new Int32Array(held.length);
323
+ held.forEach(({ id, refName, start, end, rank }, i) => {
324
+ index.set(id, i);
325
+ names.push(String(id));
326
+ let ref = refIndex.get(refName);
327
+ if (ref === undefined) {
328
+ ref = refNames.length;
329
+ refIndex.set(refName, ref);
330
+ refNames.push(refName);
331
+ }
332
+ lengths[i] = end - start;
333
+ refs[i] = ref;
334
+ starts[i] = start;
335
+ ranks[i] = rank;
336
+ });
337
+ // a kept node with no row of its own is named, and not drawn
338
+ const nodeIndex = (id) => {
339
+ let i = index.get(id);
340
+ if (i === undefined) {
341
+ i = names.length;
342
+ index.set(id, i);
343
+ names.push(String(id));
344
+ }
345
+ return i;
346
+ };
347
+ const byLine = new Map();
348
+ for (let i = 0; i < this.links.length; i += 2) {
349
+ const a = this.links[i];
350
+ const b = this.links[i + 1];
351
+ const source = Math.floor(a / 2);
352
+ const target = Math.floor(b / 2);
353
+ if (kept.has(source) && kept.has(target)) {
354
+ byLine.set(`${source}\t${a % 2 ? '-' : '+'}\t${target}\t${b % 2 ? '-' : '+'}`, i);
355
+ }
356
+ }
357
+ const lines = [...byLine.keys()].sort();
358
+ const from = new Int32Array(lines.length);
359
+ const to = new Int32Array(lines.length);
360
+ const strands = new Uint8Array(lines.length);
361
+ lines.forEach((line, k) => {
362
+ const i = byLine.get(line);
363
+ const a = this.links[i];
364
+ const b = this.links[i + 1];
365
+ from[k] = nodeIndex(Math.floor(a / 2));
366
+ to[k] = nodeIndex(Math.floor(b / 2));
367
+ strands[k] = (a % 2) | ((b % 2) << 1);
368
+ });
369
+ const walks = this.referenceFirst(fragments);
370
+ const offsets = new Int32Array(walks.length + 1);
371
+ walks.forEach(({ ids }, p) => {
372
+ offsets[p + 1] = offsets[p] + ids.length;
373
+ });
374
+ const steps = new Int32Array(offsets[walks.length]);
375
+ const reversed = new Uint8Array(steps.length);
376
+ walks.forEach(({ ids, rev }, p) => {
377
+ const at = offsets[p];
378
+ for (let j = 0; j < ids.length; j++) {
379
+ steps[at + j] = nodeIndex(ids[j]);
380
+ }
381
+ reversed.set(rev, at);
382
+ });
383
+ return {
384
+ nodes: { names, lengths, refs, starts, ranks, refNames },
385
+ links: { from, to, strands },
386
+ walks: {
387
+ names: walks.map(f => f.name),
388
+ starts: Float64Array.from(walks, f => f.hapStart),
389
+ ends: Float64Array.from(walks, f => f.hapEnd),
390
+ offsets,
391
+ steps,
392
+ reversed,
393
+ },
394
+ };
395
+ }
396
+ // The view anchors on the first walk when no walk names the assembly, as
397
+ // gbz-base's cut has it; in file order that was whichever haplotype the
398
+ // chunk listed first, CHM13 on a GRCh38 window
399
+ referenceFirst(fragments) {
400
+ const { refName } = this.window;
401
+ return [...fragments].sort((a, b) => Number(b.name === refName) - Number(a.name === refName) ||
402
+ (a.name < b.name ? -1 : a.name > b.name ? 1 : a.hapStart - b.hapStart));
403
+ }
404
+ }
405
+ export const BUDGET_NAMES = {
406
+ steps: 'the step budget',
407
+ bytes: 'a byte budget of',
408
+ };
409
+ export function stepBudgetError(rows, stepBudget, readPerKept, window, chunk, filtered, names = BUDGET_NAMES) {
410
+ var _a;
411
+ let total = 0;
412
+ const perChunk = new Map();
413
+ const walks = new Set();
414
+ for (const row of rows) {
415
+ total += row.n;
416
+ perChunk.set(row.chunkStart, ((_a = perChunk.get(row.chunkStart)) !== null && _a !== void 0 ? _a : 0) + row.n);
417
+ walks.add(row.name);
418
+ }
419
+ const budget = stepBudget * readPerKept;
420
+ if (total <= budget) {
421
+ return undefined;
422
+ }
423
+ const windowBp = window.end - window.start;
424
+ let read = 0;
425
+ let fitsBp = 0;
426
+ for (const cs of [...perChunk.keys()].sort((a, b) => a - b)) {
427
+ read += perChunk.get(cs);
428
+ if (read > budget) {
429
+ break;
430
+ }
431
+ fitsBp = Math.max(fitsBp, cs + chunk - window.start);
432
+ }
433
+ const fewerFit = !filtered && walks.size > 2 && (2 * total) / walks.size <= budget;
434
+ return new RegionTooLargeError(fitsBp > 0
435
+ ? `Zoom in to about ${bpString(fitsBp)}${fewerFit ? ', or choose fewer haplotypes,' : ''} to see the graph`
436
+ : `Too many haplotype steps here to draw ${filtered ? 'these haplotypes' : 'every haplotype'} (${read.toLocaleString()} steps read, over ${readPerKept} × ${names.steps} of ${stepBudget.toLocaleString()}); choose fewer haplotypes`, budget, windowBp, Math.max(fitsBp, 1));
437
+ }
438
+ /**
439
+ * The zoom-in notice for a cut that keeps more steps than `budget`, or
440
+ * undefined when it fits. The span that fits is the window scaled by how far
441
+ * over the cut is.
442
+ */
443
+ export function keptStepsError(fragments, budget, window, filtered) {
444
+ const total = fragments.reduce((sum, f) => sum + f.ids.length, 0);
445
+ if (total <= budget) {
446
+ return undefined;
447
+ }
448
+ const windowBp = window.end - window.start;
449
+ const fitsBp = Math.max(1, Math.floor((windowBp * budget) / total));
450
+ return new RegionTooLargeError(`Zoom in to about ${bpString(fitsBp)}${filtered ? '' : ', or choose fewer haplotypes,'} to see the graph`, budget, windowBp, fitsBp);
451
+ }
452
+ /**
453
+ * The zoom-in notice for a cut whose three files would fetch more than
454
+ * `budget` compressed bytes, or undefined when they fit. `bytesTo(end)` is the
455
+ * indexes' estimate for a read from the window's first query base to `end`;
456
+ * the span that fits grows a chunk at a time from the chunk holding the
457
+ * window's start, and when that chunk and those before it are over on their
458
+ * own, no zoom fits.
459
+ */
460
+ export async function byteBudgetError(bytesTo, budget, window, chunk, names = BUDGET_NAMES) {
461
+ const bytes = await bytesTo(window.end);
462
+ if (bytes <= budget) {
463
+ return undefined;
464
+ }
465
+ let fitsBp = 0;
466
+ let least;
467
+ for (let cs = Math.floor(window.start / chunk) * chunk; cs < window.end; cs += chunk) {
468
+ const read = await bytesTo(cs + 1);
469
+ least !== null && least !== void 0 ? least : (least = read);
470
+ if (read > budget) {
471
+ break;
472
+ }
473
+ fitsBp = cs + chunk - window.start;
474
+ }
475
+ const windowBp = window.end - window.start;
476
+ return new RegionTooLargeError(fitsBp > 0
477
+ ? `Zoom in to about ${bpString(fitsBp)} to see the graph`
478
+ : `Too much graph here to fetch (${megabytes(least !== null && least !== void 0 ? least : bytes)} against ${names.bytes} ${megabytes(budget)})`, budget, windowBp, Math.max(fitsBp, 1));
479
+ }
480
+ const megabytes = (bytes) => `${(bytes / 1e6).toFixed(1)} MB`;
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@jbrowse/graph-walks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0",
4
+ "description": "Read gfa-to-tabix walk files by range requests and cut a window of a pangenome graph: the reference, every haplotype's walk through it, and the nodes and links they visit",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Colin Diesh",
8
+ "email": "colin.diesh@gmail.com",
9
+ "url": "https://github.com/cmdcolin"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/GMOD/graph-walks.git"
14
+ },
15
+ "keywords": [
16
+ "pangenome",
17
+ "graph",
18
+ "gfa",
19
+ "tabix",
20
+ "haplotype"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./esm/index.d.ts",
26
+ "default": "./esm/index.js"
27
+ }
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "files": [
33
+ "esm"
34
+ ],
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "devDependencies": {
39
+ "@eslint/js": "^10.0.1",
40
+ "@gmod/tabix": "^3.10.1",
41
+ "@types/node": "^26.6.5",
42
+ "eslint": "^10.12.0",
43
+ "eslint-plugin-import-x": "^4.17.1",
44
+ "generic-filehandle2": "^2.5.0",
45
+ "prettier": "^3.9.10",
46
+ "typescript": "^6.0.3",
47
+ "typescript-eslint": "^8.71.1",
48
+ "vitest": "^5.0.3"
49
+ },
50
+ "scripts": {
51
+ "build": "rm -rf esm && tsc -p tsconfig.build.json",
52
+ "typecheck": "tsc --noEmit",
53
+ "lint": "eslint --report-unused-disable-directives --max-warnings 0",
54
+ "format": "prettier --write .",
55
+ "format:check": "prettier --check .",
56
+ "test": "vitest run",
57
+ "preversion": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test && pnpm build",
58
+ "postversion": "git push --follow-tags"
59
+ }
6
60
  }