@jbrowse/cli 5.0.0-beta.1 → 5.0.0-beta.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/README.md +98 -16
- package/bundle/index.js +82 -74
- package/dist/commands/add-track-utils/adapter-utils.js +49 -0
- package/dist/commands/add-track.js +26 -3
- package/dist/commands/make-density/density-generator.js +180 -0
- package/dist/commands/make-density/index.js +139 -0
- package/dist/commands/make-pif/index.js +54 -24
- package/dist/commands/make-pif/pif-generator.js +105 -133
- package/dist/commands/shared/density.js +12 -0
- package/dist/commands/validate/configManifest.generated.js +379 -8
- package/dist/commands/validate/index.js +5 -5
- package/dist/commands/validate/validateConfig.js +97 -32
- package/dist/index.js +5 -0
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/dist/commands/make-pif/cigar-utils.js +0 -67
|
@@ -4,14 +4,29 @@ import path from 'node:path';
|
|
|
4
4
|
import { Transform } from 'node:stream';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
import { createGunzip } from 'node:zlib';
|
|
7
|
-
import { csToCigar, flipCigar,
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
import { coarsenCigar, csToCigar, flipCigar, flipCoarseCigar, swapCoarseCigar, swapIndelCigar, } from '@jbrowse/cigar-utils';
|
|
8
|
+
// Default gap (bp) of the coarse tier's coarse CIGAR: how far a straight line
|
|
9
|
+
// across one of its runs may be from the alignment's real path. Indels longer
|
|
10
|
+
// than half of it keep their letter; shorter ones fold into the runs. 10kb
|
|
11
|
+
// matches the adapter's default coarseBpPerPxThreshold, so at the zoom where the
|
|
12
|
+
// tier is served the bound is ~1px and a folded indel was sub-pixel anyway.
|
|
13
|
+
export const DEFAULT_COARSE_GAP = 10_000;
|
|
14
|
+
/**
|
|
15
|
+
* The one meta line a PIF carries, sorted first by the C-locale sort and kept
|
|
16
|
+
* by tabix as a header. It states the format generation, the tiers written,
|
|
17
|
+
* the coarse tier's accuracy bound (`--coarse`), and whether every input row
|
|
18
|
+
* had a CIGAR — the facts a reader cannot recover from the rows.
|
|
19
|
+
*/
|
|
20
|
+
export function pifHeader(coarseGap, stats) {
|
|
21
|
+
const cigars = stats.cigarRows === 0
|
|
22
|
+
? 'none'
|
|
23
|
+
: stats.cigarRows === stats.rows && stats.unboundedRows === 0
|
|
24
|
+
? 'all'
|
|
25
|
+
: 'some';
|
|
26
|
+
const tiers = coarseGap === undefined ? 'fine' : 'fine,coarse';
|
|
27
|
+
const bound = coarseGap === undefined ? '' : `\tcoarse:i:${coarseGap}`;
|
|
28
|
+
return `#pif\tversion:i:1\ttiers:Z:${tiers}${bound}\tcigars:Z:${cigars}\n`;
|
|
29
|
+
}
|
|
15
30
|
function panSNSample(refName) {
|
|
16
31
|
const i = refName.indexOf('#');
|
|
17
32
|
return i === -1 ? undefined : refName.slice(0, i);
|
|
@@ -47,34 +62,33 @@ export function missingPairs({ samples, pairs }) {
|
|
|
47
62
|
}
|
|
48
63
|
return out;
|
|
49
64
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
: [whole];
|
|
65
|
+
// The coarse row's `cr:Z:` value, or nothing, and whether withholding it left
|
|
66
|
+
// the row making a claim it cannot keep.
|
|
67
|
+
//
|
|
68
|
+
// `ops` is nothing when the row has no CIGAR, when the fold is a single run
|
|
69
|
+
// (the coordinate columns already say it all), and when the walk does not close
|
|
70
|
+
// on the row's own far corner — clipping ops, a hand-made cg, a cs whose spans
|
|
71
|
+
// don't add up — since the columns are what the fine tier draws and the coarse
|
|
72
|
+
// row must not disagree with them. A fold of several runs with no kept indel is
|
|
73
|
+
// still written: the runs are where a lopsided cluster of small indels bends
|
|
74
|
+
// the path, which a straight ribbon across the row would miss by up to the
|
|
75
|
+
// whole cluster.
|
|
76
|
+
//
|
|
77
|
+
// `unbounded` separates the last of those from the rest. The first two leave a
|
|
78
|
+
// tagless row a reader may take as one run within `--coarse`, and it is one; a
|
|
79
|
+
// walk that misses its own corner leaves a row that reads the same way and is
|
|
80
|
+
// not, so the header has to stop saying `cigars:Z:all` on its account.
|
|
81
|
+
function coarseFold({ cigar, coarseGap, ownLen, mateLen, }) {
|
|
82
|
+
const coarse = cigar === undefined ? undefined : coarsenCigar(cigar, coarseGap);
|
|
83
|
+
const closed = coarse !== undefined &&
|
|
84
|
+
coarse.ownLen === ownLen &&
|
|
85
|
+
coarse.mateLen === mateLen;
|
|
86
|
+
return {
|
|
87
|
+
ops: closed && (coarse.gapCount > 0 || coarse.opCount > 1)
|
|
88
|
+
? coarse.ops
|
|
89
|
+
: undefined,
|
|
90
|
+
unbounded: cigar !== undefined && !closed,
|
|
91
|
+
};
|
|
78
92
|
}
|
|
79
93
|
// A PIF row carries exactly ONE alignment string — `cg:Z:`, in the orientation
|
|
80
94
|
// of the perspective it is indexed under. A minimap2 `cs:Z:` is folded into that
|
|
@@ -102,34 +116,11 @@ function foldCsIntoCg(tags) {
|
|
|
102
116
|
const rewritten = tags.flatMap((f, i) => i === csIdx ? [folded] : f.startsWith('cg:Z') ? [] : [f]);
|
|
103
117
|
return { tags: rewritten, cigarIdx: rewritten.indexOf(folded) };
|
|
104
118
|
}
|
|
105
|
-
// Coarse identity must match the fine tier exactly, or coloring jumps at the
|
|
106
|
-
// zoom where the view switches tiers. So it is written off the SAME function the
|
|
107
|
-
// adapters read the fine tier with (de:f: tag, then odgi's id:f:, then
|
|
108
|
-
// num_matches/block_len) rather than a private copy of that chain — a copy is
|
|
109
|
-
// what previously skipped the id:f: rung. A CIGAR recompute is deliberately not
|
|
110
|
-
// in the chain at all: a cg (M-style) CIGAR folds mismatches into M, so it would
|
|
111
|
-
// report ~0 divergence (spurious 100% identity) for a divergent alignment.
|
|
112
|
-
//
|
|
113
|
-
// When the row's own de:f: is what the reader lands on, that string is passed
|
|
114
|
-
// through byte-for-byte rather than recomputed: a round trip through toFixed(6)
|
|
115
|
-
// would silently truncate a 7-decimal tag and drift the two tiers apart by the
|
|
116
|
-
// exact mechanism this is guarding against.
|
|
117
|
-
function coarseDivergence(tags, numMatches, blockLen) {
|
|
118
|
-
const rawDe = tags.find(f => f.startsWith('de:f:'))?.slice(5);
|
|
119
|
-
const identity = pafIdentity({
|
|
120
|
-
de: rawDe,
|
|
121
|
-
id: tags.find(f => f.startsWith('id:f:'))?.slice(5),
|
|
122
|
-
numMatches,
|
|
123
|
-
blockLen,
|
|
124
|
-
});
|
|
125
|
-
return rawDe !== undefined && 1 - +rawDe === identity
|
|
126
|
-
? rawDe
|
|
127
|
-
: (1 - identity).toFixed(6);
|
|
128
|
-
}
|
|
129
119
|
function pifRow(fields) {
|
|
130
120
|
return `${fields.join('\t')}\n`;
|
|
131
121
|
}
|
|
132
|
-
function processLine(
|
|
122
|
+
function processLine(rawLine, coarseGap, stats) {
|
|
123
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
133
124
|
if (line.startsWith('#')) {
|
|
134
125
|
return '';
|
|
135
126
|
}
|
|
@@ -144,8 +135,14 @@ function processLine(line, coarseSplitGap, stats) {
|
|
|
144
135
|
// rest[0]=num_matches, rest[1]=block_len, rest[2]=mapq, rest[3+]=optional tags
|
|
145
136
|
const [c1, l1, s1, e1, strand, c2, l2, s2, e2, ...rest] = parts;
|
|
146
137
|
addPanSNPair(stats, c1, c2);
|
|
147
|
-
|
|
138
|
+
// an incoming cr:Z: (a PIF turned back into PAF, or a tool that adopted the
|
|
139
|
+
// tag) is dropped from both tiers: the fine tier never carries one, and the
|
|
140
|
+
// coarse tier writes its own below
|
|
141
|
+
const { tags, cigarIdx } = foldCsIntoCg(rest.filter(f => !f.startsWith('cr:Z:')));
|
|
148
142
|
const cigar = cigarIdx === -1 ? undefined : tags[cigarIdx].slice(5);
|
|
143
|
+
if (cigar !== undefined) {
|
|
144
|
+
stats.cigarRows++;
|
|
145
|
+
}
|
|
149
146
|
// the t-row keeps the CIGAR as PAF spelled it (target perspective); the q-row
|
|
150
147
|
// re-orients it for the query perspective it is indexed under
|
|
151
148
|
const queryTags = cigar === undefined
|
|
@@ -155,73 +152,33 @@ function processLine(line, coarseSplitGap, stats) {
|
|
|
155
152
|
: f);
|
|
156
153
|
const fineRows = pifRow([`t${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...tags]) +
|
|
157
154
|
pifRow([`q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...queryTags]);
|
|
158
|
-
if (
|
|
155
|
+
if (coarseGap === undefined) {
|
|
159
156
|
return fineRows;
|
|
160
157
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
158
|
+
// the coarse row is the fine row with its CIGAR replaced by the fold and
|
|
159
|
+
// every other column and tag verbatim, so a click and the identity coloring
|
|
160
|
+
// read the same on both tiers
|
|
161
|
+
const coarse = cigarIdx === -1 ? tags : tags.filter((_, i) => i !== cigarIdx);
|
|
162
|
+
// the T row's own axis is the target, as the PAF CIGAR is written; the Q row
|
|
163
|
+
// re-orients it for the query, the way the fine tier's cg is
|
|
164
|
+
const { ops: cr, unbounded } = coarseFold({
|
|
165
|
+
cigar,
|
|
166
|
+
coarseGap,
|
|
167
|
+
ownLen: +e2 - +s2,
|
|
168
|
+
mateLen: +e1 - +s1,
|
|
169
|
+
});
|
|
170
|
+
if (unbounded) {
|
|
171
|
+
stats.unboundedRows++;
|
|
172
|
+
}
|
|
173
|
+
const tCr = cr === undefined ? [] : [`cr:Z:${cr}`];
|
|
174
|
+
const qCr = cr === undefined
|
|
175
|
+
? []
|
|
176
|
+
: [`cr:Z:${strand === '-' ? flipCoarseCigar(cr) : swapCoarseCigar(cr)}`];
|
|
174
177
|
return (fineRows +
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
strand: strand,
|
|
178
|
-
tstart: +s2,
|
|
179
|
-
tend: +e2,
|
|
180
|
-
qstart: +s1,
|
|
181
|
-
qend: +e1,
|
|
182
|
-
numMatches,
|
|
183
|
-
blockLen,
|
|
184
|
-
splitGap: coarseSplitGap,
|
|
185
|
-
})
|
|
186
|
-
.flatMap(piece => {
|
|
187
|
-
const tail = [
|
|
188
|
-
piece.numMatches,
|
|
189
|
-
piece.blockLen,
|
|
190
|
-
mapq,
|
|
191
|
-
...passthrough,
|
|
192
|
-
`de:f:${de}`,
|
|
193
|
-
];
|
|
194
|
-
const { tstart, tend, qstart, qend } = piece;
|
|
195
|
-
return [
|
|
196
|
-
pifRow([
|
|
197
|
-
`T${c2}`,
|
|
198
|
-
l2,
|
|
199
|
-
tstart,
|
|
200
|
-
tend,
|
|
201
|
-
strand,
|
|
202
|
-
c1,
|
|
203
|
-
l1,
|
|
204
|
-
qstart,
|
|
205
|
-
qend,
|
|
206
|
-
...tail,
|
|
207
|
-
]),
|
|
208
|
-
pifRow([
|
|
209
|
-
`Q${c1}`,
|
|
210
|
-
l1,
|
|
211
|
-
qstart,
|
|
212
|
-
qend,
|
|
213
|
-
strand,
|
|
214
|
-
c2,
|
|
215
|
-
l2,
|
|
216
|
-
tstart,
|
|
217
|
-
tend,
|
|
218
|
-
...tail,
|
|
219
|
-
]),
|
|
220
|
-
];
|
|
221
|
-
})
|
|
222
|
-
.join(''));
|
|
178
|
+
pifRow([`T${c2}`, l2, s2, e2, strand, c1, l1, s1, e1, ...coarse, ...tCr]) +
|
|
179
|
+
pifRow([`Q${c1}`, l1, s1, e1, strand, c2, l2, s2, e2, ...coarse, ...qCr]));
|
|
223
180
|
}
|
|
224
|
-
function makePifTransform(
|
|
181
|
+
function makePifTransform(coarseGap, stats) {
|
|
225
182
|
let tail = '';
|
|
226
183
|
return new Transform({
|
|
227
184
|
transform(chunk, _enc, callback) {
|
|
@@ -237,25 +194,31 @@ function makePifTransform(coarseSplitGap, stats) {
|
|
|
237
194
|
.slice(0, lastNl)
|
|
238
195
|
.split('\n')
|
|
239
196
|
.filter(Boolean)
|
|
240
|
-
.map(l => processLine(l,
|
|
197
|
+
.map(l => processLine(l, coarseGap, stats))
|
|
241
198
|
.join(''));
|
|
242
199
|
},
|
|
243
200
|
flush(callback) {
|
|
244
|
-
|
|
201
|
+
// the header goes out last and sorts first: the counts it states are
|
|
202
|
+
// only known once every row has been seen, and `#` precedes every tier
|
|
203
|
+
// letter in the C locale
|
|
204
|
+
callback(null, (tail ? processLine(tail, coarseGap, stats) : '') +
|
|
205
|
+
pifHeader(coarseGap, stats));
|
|
245
206
|
},
|
|
246
207
|
});
|
|
247
208
|
}
|
|
248
209
|
// resolves to what the pass observed: the PanSN samples (empty for a pairwise
|
|
249
210
|
// PAF), so the caller can suggest the right adapter and assembly names, and the
|
|
250
211
|
// row/skip counts so it can warn on a file that was not PAF at all
|
|
251
|
-
export async function createPIF(filename, stream,
|
|
212
|
+
export async function createPIF(filename, stream, coarseGap) {
|
|
252
213
|
const stats = {
|
|
253
214
|
samples: new Set(),
|
|
254
215
|
pairs: new Set(),
|
|
255
216
|
rows: 0,
|
|
217
|
+
cigarRows: 0,
|
|
218
|
+
unboundedRows: 0,
|
|
256
219
|
skipped: 0,
|
|
257
220
|
};
|
|
258
|
-
const transform = makePifTransform(
|
|
221
|
+
const transform = makePifTransform(coarseGap, stats);
|
|
259
222
|
if (filename) {
|
|
260
223
|
const source = createReadStream(filename);
|
|
261
224
|
await (/\.b?gz$/i.test(filename)
|
|
@@ -267,15 +230,24 @@ export async function createPIF(filename, stream, coarseSplitGap) {
|
|
|
267
230
|
}
|
|
268
231
|
return stats;
|
|
269
232
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
233
|
+
// bgzip is the only stage of this pipeline that cannot use more than one core on
|
|
234
|
+
// its own: sort parallelizes itself and tabix is I/O bound. 4 is enough to take
|
|
235
|
+
// compression off the critical path on the files this command is pointed at
|
|
236
|
+
// without claiming a whole machine.
|
|
237
|
+
export const DEFAULT_BGZIP_THREADS = 4;
|
|
238
|
+
export function spawnSortProcess(outputFile, useCsi, threads = DEFAULT_BGZIP_THREADS) {
|
|
239
|
+
// -s: tabix wants the rows ordered by (refName, start) and by nothing else,
|
|
240
|
+
// so the whole-line comparison a non-stable sort falls back to on a tie is
|
|
241
|
+
// comparing multi-kb CIGARs to pick an order no reader can observe
|
|
242
|
+
const sortCmd = `sort -s -t"\`printf '\\t'\`" -k1,1 -k3,3n`;
|
|
243
|
+
const bgzipCommand = `bgzip -@ ${threads} > "$1"`;
|
|
273
244
|
const tabixCommand = `tabix ${useCsi ? '-C ' : ''}-s1 -b3 -e4 -0 "$1"`;
|
|
274
245
|
// `&&` (not `;`) so a bgzip failure aborts before tabix runs and propagates
|
|
275
246
|
// as the pipeline's exit code. The output path is passed as the shell's "$1"
|
|
276
247
|
// positional rather than interpolated into the command string, so a path
|
|
277
248
|
// with shell metacharacters (`"`, `$(...)`, backticks) can't break out and
|
|
278
|
-
// execute — same technique as sort-utils.ts. useCsi is a fixed literal
|
|
249
|
+
// execute — same technique as sort-utils.ts. useCsi is a fixed literal, and
|
|
250
|
+
// the caller has already checked threads is a positive integer.
|
|
279
251
|
const fullCommand = `${sortCmd} | ${bgzipCommand} && ${tabixCommand}`;
|
|
280
252
|
return spawn('sh', ['-c', fullCommand, 'sh', outputFile], {
|
|
281
253
|
env: { ...process.env, LC_ALL: 'C' },
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the density sidecar for a data file lives: `<file>.density.bw`, with a
|
|
3
|
+
* `.gz`/`.bgz` stripped first so `genes.gff3.gz` yields `genes.gff3.density.bw`
|
|
4
|
+
* rather than a name carrying the compression of a file it is not compressed
|
|
5
|
+
* like.
|
|
6
|
+
*
|
|
7
|
+
* One rule, two commands: `make-density` writes here by default and `add-track`
|
|
8
|
+
* probes here, so a sidecar built with no `--out` is the one the track picks up.
|
|
9
|
+
*/
|
|
10
|
+
export function densitySidecarPath(file) {
|
|
11
|
+
return `${file.replace(/\.b?gz$/i, '')}.density.bw`;
|
|
12
|
+
}
|