@jbrowse/cli 4.3.0 → 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 +413 -57
- package/bin/run +1 -1
- package/bundle/index.js +550 -2659
- package/dist/base.js +0 -3
- package/dist/bin.js +2 -1
- package/dist/cliFetch.js +3 -0
- package/dist/commands/add-assembly/index.js +46 -19
- package/dist/commands/add-assembly/utils.js +204 -290
- package/dist/commands/add-connection.js +73 -79
- package/dist/commands/add-track-json.js +24 -12
- package/dist/commands/add-track-utils/adapter-utils.js +222 -288
- package/dist/commands/add-track-utils/file-operations.js +27 -11
- package/dist/commands/add-track-utils/multiwig.js +47 -0
- package/dist/commands/add-track-utils/track-config.js +68 -25
- package/dist/commands/add-track-utils/validators.js +36 -20
- package/dist/commands/add-track.js +201 -62
- package/dist/commands/admin-server/index.js +30 -49
- package/dist/commands/admin-server/utils.js +235 -125
- package/dist/commands/create.js +18 -34
- 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 +128 -11
- package/dist/commands/make-pif/pif-generator.js +249 -43
- package/dist/commands/process-utils.js +13 -2
- package/dist/commands/remove-track.js +20 -13
- package/dist/commands/set-default-session.js +35 -31
- package/dist/commands/shared/config-operations.js +3 -10
- package/dist/commands/shared/density.js +12 -0
- package/dist/commands/shared/sort-utils.js +67 -10
- package/dist/commands/shared/validators.js +38 -3
- package/dist/commands/sort-bed.js +2 -33
- package/dist/commands/sort-gff.js +2 -33
- package/dist/commands/text-index/adapter-utils.js +6 -23
- package/dist/commands/text-index/aggregate.js +40 -34
- package/dist/commands/text-index/command.js +35 -7
- package/dist/commands/text-index/config-utils.js +60 -83
- package/dist/commands/text-index/file-list.js +32 -18
- package/dist/commands/text-index/index.js +4 -4
- package/dist/commands/text-index/indexing-utils.js +49 -73
- package/dist/commands/text-index/per-track.js +44 -31
- package/dist/commands/text-index/validators.js +1 -1
- package/dist/commands/upgrade.js +20 -32
- package/dist/commands/validate/configManifest.generated.js +5765 -0
- package/dist/commands/validate/displayDefaultKeys.js +28 -0
- package/dist/commands/validate/index.js +97 -0
- package/dist/commands/validate/suggest.js +35 -0
- package/dist/commands/validate/types.js +6 -0
- package/dist/commands/validate/validateConfig.js +522 -0
- package/dist/index.js +104 -50
- package/dist/types/common.js +3 -27
- package/dist/utils.js +309 -128
- package/dist/version.js +1 -1
- package/package.json +8 -10
- package/dist/commands/make-pif/cigar-utils.js +0 -24
- package/dist/commands/make-pif/file-utils.js +0 -30
- package/dist/commands/track-utils.js +0 -74
- package/dist/util.js +0 -35
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createReadStream, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createGunzip } from 'node:zlib';
|
|
4
|
+
// Which parser a filename selects. BAM/CRAM/SAM are named so the refusal can
|
|
5
|
+
// say what to do instead, rather than falling into the unknown-extension case.
|
|
6
|
+
const formats = [
|
|
7
|
+
{ regex: /\.gff3?(\.b?gz)?$/i, format: 'gff3' },
|
|
8
|
+
{ regex: /\.gtf(\.b?gz)?$/i, format: 'gtf' },
|
|
9
|
+
{ regex: /\.vcf(\.b?gz)?$/i, format: 'vcf' },
|
|
10
|
+
{ regex: /\.bed(\.b?gz)?$/i, format: 'bed' },
|
|
11
|
+
];
|
|
12
|
+
const alignmentFormat = /\.(bam|cram|sam)$/i;
|
|
13
|
+
export function densityFormat(file) {
|
|
14
|
+
const hit = formats.find(f => f.regex.test(file));
|
|
15
|
+
if (!hit) {
|
|
16
|
+
throw new Error(alignmentFormat.test(file)
|
|
17
|
+
? `${file} is an alignment file, which make-density cannot read. Count coverage with a tool that reads BAM/CRAM (e.g. "bamCoverage" or "samtools depth" piped to bedGraphToBigWig) and pass the result with add-track --density.`
|
|
18
|
+
: `Cannot tell what kind of file ${file} is. make-density reads GFF3, GTF, BED and VCF, plain or bgzipped.`);
|
|
19
|
+
}
|
|
20
|
+
return hit.format;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The reference name and 0-based start of one record, or undefined for a line
|
|
24
|
+
* that contributes nothing: a header, a directive, a short line, or — in GFF3 —
|
|
25
|
+
* a child feature, whose start would count its parent's locus a second time.
|
|
26
|
+
* A GTF row says whether it is a `transcript`, the record its track draws;
|
|
27
|
+
* BED and VCF have no parent link to follow, so every record counts.
|
|
28
|
+
*/
|
|
29
|
+
function parseRecord(line, format) {
|
|
30
|
+
const columns = line.split('\t');
|
|
31
|
+
const refName = columns[0];
|
|
32
|
+
const startsHeader = line.startsWith('#') ||
|
|
33
|
+
line.startsWith('track ') ||
|
|
34
|
+
line.startsWith('browser ');
|
|
35
|
+
if (startsHeader || refName === undefined || refName === '') {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
if (format === 'bed') {
|
|
39
|
+
const start = columns[1];
|
|
40
|
+
return start === undefined
|
|
41
|
+
? undefined
|
|
42
|
+
: { refName, start: +start, transcript: false };
|
|
43
|
+
}
|
|
44
|
+
const start = columns[format === 'vcf' ? 1 : 3];
|
|
45
|
+
const attributes = columns[8];
|
|
46
|
+
const isChild = format === 'gff3' && attributes?.includes('Parent=');
|
|
47
|
+
return start === undefined || isChild
|
|
48
|
+
? undefined
|
|
49
|
+
: {
|
|
50
|
+
refName,
|
|
51
|
+
start: +start - 1,
|
|
52
|
+
transcript: format === 'gtf' && columns[2] === 'transcript',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function tally(bins, key, bin) {
|
|
56
|
+
const existing = bins.get(key);
|
|
57
|
+
const perRef = existing === undefined ? new Map() : existing;
|
|
58
|
+
if (existing === undefined) {
|
|
59
|
+
bins.set(key, perRef);
|
|
60
|
+
}
|
|
61
|
+
const count = perRef.get(bin);
|
|
62
|
+
perRef.set(bin, count === undefined ? 1 : count + 1);
|
|
63
|
+
}
|
|
64
|
+
export async function countFeatureStarts({ file, format, binSize, chromSizes, }) {
|
|
65
|
+
// A GTF's transcripts are tallied apart from its exons and CDS rows: where a
|
|
66
|
+
// file has transcript rows they are the count, and a file with none (an
|
|
67
|
+
// exon-only GTF) counts every row.
|
|
68
|
+
const all = new Map();
|
|
69
|
+
const transcripts = new Map();
|
|
70
|
+
const unknownRefNames = new Set();
|
|
71
|
+
let records = 0;
|
|
72
|
+
let transcriptRecords = 0;
|
|
73
|
+
const source = createReadStream(file);
|
|
74
|
+
const lines = createInterface({
|
|
75
|
+
input: /\.b?gz$/i.test(file) ? source.pipe(createGunzip()) : source,
|
|
76
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
77
|
+
});
|
|
78
|
+
for await (const line of lines) {
|
|
79
|
+
// everything after a GFF3 ##FASTA directive is sequence, not features
|
|
80
|
+
if (line.trimEnd() === '##FASTA') {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const record = parseRecord(line, format);
|
|
84
|
+
if (record && Number.isFinite(record.start)) {
|
|
85
|
+
records++;
|
|
86
|
+
transcriptRecords += record.transcript ? 1 : 0;
|
|
87
|
+
const length = chromSizes.get(record.refName);
|
|
88
|
+
if (length === undefined) {
|
|
89
|
+
unknownRefNames.add(record.refName);
|
|
90
|
+
}
|
|
91
|
+
else if (record.start >= 0 && record.start < length) {
|
|
92
|
+
const bin = Math.floor(record.start / binSize);
|
|
93
|
+
tally(all, record.refName, bin);
|
|
94
|
+
if (record.transcript) {
|
|
95
|
+
tally(transcripts, record.refName, bin);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
lines.close();
|
|
101
|
+
source.destroy();
|
|
102
|
+
return transcriptRecords > 0
|
|
103
|
+
? { bins: transcripts, records: transcriptRecords, unknownRefNames }
|
|
104
|
+
: { bins: all, records, unknownRefNames };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* bedGraph rows covering every base of every reference, in the order
|
|
108
|
+
* bedGraphToBigWig demands (`sort -k1,1 -k2,2n`): one row per counted bin,
|
|
109
|
+
* clipped to the reference's length, and one per run of empty bins between
|
|
110
|
+
* them. The empty runs are load-bearing: a bigWig's zoom levels average over
|
|
111
|
+
* the bases its rows cover, so a file that skipped them would read, zoomed out,
|
|
112
|
+
* as the mean over the bins that held something rather than over the span, and
|
|
113
|
+
* a gene desert beside a cluster would score the same.
|
|
114
|
+
*/
|
|
115
|
+
export function* bedGraphLines({ bins, chromSizes, binSize, }) {
|
|
116
|
+
const byName = [...chromSizes.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
117
|
+
for (const [refName, length] of byName) {
|
|
118
|
+
const perRef = bins.get(refName);
|
|
119
|
+
const counted = perRef
|
|
120
|
+
? [...perRef.entries()].sort(([a], [b]) => a - b)
|
|
121
|
+
: [];
|
|
122
|
+
let cursor = 0;
|
|
123
|
+
for (const [bin, count] of counted) {
|
|
124
|
+
const start = bin * binSize;
|
|
125
|
+
const end = Math.min(start + binSize, length);
|
|
126
|
+
if (start > cursor) {
|
|
127
|
+
yield `${refName}\t${cursor}\t${start}\t0\n`;
|
|
128
|
+
}
|
|
129
|
+
yield `${refName}\t${start}\t${end}\t${count}\n`;
|
|
130
|
+
cursor = end;
|
|
131
|
+
}
|
|
132
|
+
if (cursor < length) {
|
|
133
|
+
yield `${refName}\t${cursor}\t${length}\t0\n`;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function chromSizesLines(chromSizes) {
|
|
138
|
+
return [...chromSizes.entries()]
|
|
139
|
+
.map(([refName, length]) => `${refName}\t${length}\n`)
|
|
140
|
+
.join('');
|
|
141
|
+
}
|
|
142
|
+
// a `.chrom.sizes` and a `.fai` both put the name in column 1 and the length in
|
|
143
|
+
// column 2, so one parser reads either
|
|
144
|
+
function parseChromSizes(text, source) {
|
|
145
|
+
const sizes = new Map();
|
|
146
|
+
for (const line of text.split('\n')) {
|
|
147
|
+
const [refName, length] = line.split('\t');
|
|
148
|
+
if (refName && length !== undefined && Number.isFinite(+length)) {
|
|
149
|
+
sizes.set(refName, +length);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (sizes.size === 0) {
|
|
153
|
+
throw new Error(`No reference sequences found in ${source}`);
|
|
154
|
+
}
|
|
155
|
+
return sizes;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The reference lengths the bigWig header needs, from an explicit
|
|
159
|
+
* `--chrom-sizes` or from the `.fai` beside an `--assembly` FASTA.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveChromSizes({ chromSizes, assembly, }) {
|
|
162
|
+
if (chromSizes !== undefined) {
|
|
163
|
+
return parseChromSizes(readFileSync(chromSizes, 'utf8'), chromSizes);
|
|
164
|
+
}
|
|
165
|
+
if (assembly !== undefined) {
|
|
166
|
+
const fai = `${assembly}.fai`;
|
|
167
|
+
if (!existsSync(fai)) {
|
|
168
|
+
throw new Error(`No index beside ${assembly}: expected ${fai}. Make one with "samtools faidx ${assembly}", or pass --chrom-sizes instead.`);
|
|
169
|
+
}
|
|
170
|
+
return parseChromSizes(readFileSync(fai, 'utf8'), fai);
|
|
171
|
+
}
|
|
172
|
+
throw new Error('Reference lengths are required: pass --chrom-sizes <file> or --assembly <fasta> (whose .fai is read)');
|
|
173
|
+
}
|
|
174
|
+
export function parseBinSize(bin) {
|
|
175
|
+
const binSize = +bin;
|
|
176
|
+
if (!Number.isInteger(binSize) || binSize < 1) {
|
|
177
|
+
throw new Error(`--bin must be a positive whole number of bp, got ${bin}`);
|
|
178
|
+
}
|
|
179
|
+
return binSize;
|
|
180
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createWriteStream, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
import { printHelp, requirePositional } from '../../utils.js';
|
|
9
|
+
import { describeExit, waitForProcessClose } from '../process-utils.js';
|
|
10
|
+
import { densitySidecarPath } from '../shared/density.js';
|
|
11
|
+
import { validateFileArgument, validateRequiredCommands, } from '../shared/validators.js';
|
|
12
|
+
import { bedGraphLines, chromSizesLines, countFeatureStarts, densityFormat, parseBinSize, resolveChromSizes, } from './density-generator.js';
|
|
13
|
+
export async function run(args) {
|
|
14
|
+
const options = {
|
|
15
|
+
help: {
|
|
16
|
+
type: 'boolean',
|
|
17
|
+
short: 'h',
|
|
18
|
+
},
|
|
19
|
+
bin: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
default: '1000',
|
|
22
|
+
description: 'Width of each bin, in bp',
|
|
23
|
+
},
|
|
24
|
+
'chrom-sizes': {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Two-column reference name and length table for the assembly the file is on',
|
|
27
|
+
},
|
|
28
|
+
assembly: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'FASTA of the assembly the file is on; its .fai supplies the reference lengths, in place of --chrom-sizes',
|
|
31
|
+
},
|
|
32
|
+
out: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Where to write the bigWig. Defaults to <file>.density.bw beside the input, which is where add-track looks for it',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const { values: flags, positionals } = parseArgs({
|
|
38
|
+
args,
|
|
39
|
+
options,
|
|
40
|
+
allowPositionals: true,
|
|
41
|
+
});
|
|
42
|
+
const description = 'Counts feature starts per bin into a bigWig, the density sidecar a track draws where the region is too large to fetch its features';
|
|
43
|
+
const notes = "The sidecar is attached to a track through its adapter's densityAdapter " +
|
|
44
|
+
'slot, which BamAdapter, CramAdapter, HtsgetBamAdapter, Gff3TabixAdapter, ' +
|
|
45
|
+
'GtfTabixAdapter, BedTabixAdapter, BigBedAdapter, VcfTabixAdapter and ' +
|
|
46
|
+
'SplitVcfTabixAdapter declare. add-track attaches it on its own when the ' +
|
|
47
|
+
'default <file>.density.bw sits beside the track file, so building it before ' +
|
|
48
|
+
'adding the track is all it takes; --density attaches one by path or URL.\n\n' +
|
|
49
|
+
'GFF3 counts only top-level features — a line whose attributes carry no ' +
|
|
50
|
+
'Parent= — so a gene is one count rather than one per exon. GTF, BED and VCF ' +
|
|
51
|
+
'have no parent link to follow, so every record counts.\n\n' +
|
|
52
|
+
'BAM and CRAM are out of scope: read depth is not feature density, and the ' +
|
|
53
|
+
'tools that compute it already write bigWig. Point add-track --density at ' +
|
|
54
|
+
'what they produce.\n\n' +
|
|
55
|
+
'Requires bedGraphToBigWig (UCSC) on the PATH.';
|
|
56
|
+
const examples = [
|
|
57
|
+
'# writes genes.gff3.density.bw beside the input, in 1kb bins',
|
|
58
|
+
'$ jbrowse make-density genes.gff3.gz --chrom-sizes hg38.chrom.sizes',
|
|
59
|
+
'',
|
|
60
|
+
'# take the reference lengths from the assembly FASTA index instead',
|
|
61
|
+
'$ jbrowse make-density variants.vcf.gz --assembly hg38.fa',
|
|
62
|
+
'',
|
|
63
|
+
'# coarser bins, and a name of your own',
|
|
64
|
+
'$ jbrowse make-density genes.gff3.gz --assembly hg38.fa --bin 10000 --out genes.10kb.bw',
|
|
65
|
+
];
|
|
66
|
+
const usage = 'jbrowse make-density <file> [options]';
|
|
67
|
+
if (flags.help) {
|
|
68
|
+
printHelp({
|
|
69
|
+
description,
|
|
70
|
+
examples,
|
|
71
|
+
notes,
|
|
72
|
+
usage,
|
|
73
|
+
options,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const file = positionals[0];
|
|
78
|
+
requirePositional(file, 'file', usage);
|
|
79
|
+
// ahead of the bedGraphToBigWig check, so a file this command cannot read
|
|
80
|
+
// says so even where UCSC's tools are not installed
|
|
81
|
+
const format = densityFormat(file);
|
|
82
|
+
validateFileArgument(file, 'make-density', format);
|
|
83
|
+
const binSize = parseBinSize(flags.bin);
|
|
84
|
+
const chromSizes = resolveChromSizes({
|
|
85
|
+
chromSizes: flags['chrom-sizes'],
|
|
86
|
+
assembly: flags.assembly,
|
|
87
|
+
});
|
|
88
|
+
validateRequiredCommands(['bedGraphToBigWig']);
|
|
89
|
+
const outputFile = flags.out === undefined ? densitySidecarPath(file) : flags.out;
|
|
90
|
+
const { bins, records, unknownRefNames } = await countFeatureStarts({
|
|
91
|
+
file,
|
|
92
|
+
format,
|
|
93
|
+
binSize,
|
|
94
|
+
chromSizes,
|
|
95
|
+
});
|
|
96
|
+
if (unknownRefNames.size > 0) {
|
|
97
|
+
const shown = [...unknownRefNames].slice(0, 5).join(', ');
|
|
98
|
+
const rest = unknownRefNames.size - Math.min(unknownRefNames.size, 5);
|
|
99
|
+
console.warn(`Warning: skipped records on ${unknownRefNames.size} reference name(s) absent from the reference lengths (${shown}${rest > 0 ? `, and ${rest} more` : ''}). A chr-prefixed file against an unprefixed assembly looks exactly like this.`);
|
|
100
|
+
}
|
|
101
|
+
// A file that yielded nothing is refused rather than written as a flat
|
|
102
|
+
// band, which would read as "no features here" rather than "wrong file"
|
|
103
|
+
const binCount = [...bins.values()].reduce((sum, perRef) => sum + perRef.size, 0);
|
|
104
|
+
if (binCount === 0) {
|
|
105
|
+
throw new Error(`No feature starts found in ${file} on any reference in the given lengths (${records} record(s) read). Is this the right file and assembly?`);
|
|
106
|
+
}
|
|
107
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'jbrowse-density-'));
|
|
108
|
+
try {
|
|
109
|
+
const bedGraph = path.join(tmpDir, 'density.bedGraph');
|
|
110
|
+
// normalized rather than the caller's file, so a .fai (five columns) and a
|
|
111
|
+
// chrom.sizes with stray whitespace both reach bedGraphToBigWig as the two
|
|
112
|
+
// columns it reads
|
|
113
|
+
const sizes = path.join(tmpDir, 'reference.chrom.sizes');
|
|
114
|
+
writeFileSync(sizes, chromSizesLines(chromSizes));
|
|
115
|
+
await pipeline(Readable.from(bedGraphLines({ bins, chromSizes, binSize })), createWriteStream(bedGraph));
|
|
116
|
+
const child = spawn('bedGraphToBigWig', [bedGraph, sizes, outputFile], {
|
|
117
|
+
stdio: ['ignore', process.stdout, process.stderr],
|
|
118
|
+
});
|
|
119
|
+
const exit = await waitForProcessClose(child);
|
|
120
|
+
if (exit.code !== 0) {
|
|
121
|
+
throw new Error(`bedGraphToBigWig exited with ${describeExit(exit)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
// The slot lives on the indexed adapters, so a plain text file has to be
|
|
128
|
+
// bgzipped and tabix-indexed before the track can carry the sidecar
|
|
129
|
+
const indexed = /\.b?gz$/i.test(file);
|
|
130
|
+
const preset = format === 'bed' ? 'bed' : format === 'vcf' ? 'vcf' : 'gff';
|
|
131
|
+
const trackFile = indexed ? file : `${file}.gz`;
|
|
132
|
+
const indexFirst = indexed
|
|
133
|
+
? ''
|
|
134
|
+
: ` bgzip ${file} && tabix -p ${preset} ${trackFile}\n`;
|
|
135
|
+
const nextCommand = outputFile === densitySidecarPath(file)
|
|
136
|
+
? `Next, add the track it summarizes — a sidecar under this name is picked up automatically:\n${indexFirst} jbrowse add-track ${trackFile} --load copy`
|
|
137
|
+
: `Next, add the track it summarizes, naming this sidecar:\n${indexFirst} jbrowse add-track ${trackFile} --load copy --density ${outputFile}`;
|
|
138
|
+
console.log(`Created ${outputFile}: ${records} feature start(s) in ${binCount} non-empty ${binSize}bp bin(s)\n\n${nextCommand}`);
|
|
139
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import { printHelp } from
|
|
4
|
-
import {
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { printHelp } from '../../utils.js';
|
|
4
|
+
import { describeExit, waitForProcessClose } from '../process-utils.js';
|
|
5
|
+
import { validateFileArgument, validateRequiredCommands, } from '../shared/validators.js';
|
|
6
|
+
import { DEFAULT_BGZIP_THREADS, DEFAULT_COARSE_GAP, createPIF, getOutputFilename, missingPairs, spawnSortProcess, } from './pif-generator.js';
|
|
5
7
|
export async function run(args) {
|
|
6
8
|
const options = {
|
|
7
9
|
help: {
|
|
@@ -16,6 +18,18 @@ export async function run(args) {
|
|
|
16
18
|
type: 'boolean',
|
|
17
19
|
description: 'Create a CSI index for the PIF file instead of TBI',
|
|
18
20
|
},
|
|
21
|
+
coarse: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Accuracy bound (bp) of the coarse tier. A coarse row replaces its CIGAR with a coarse CIGAR (cr:Z:) that keeps every indel longer than half this length and folds the rest into runs, each run staying within this many bp of the real alignment. Defaults to 10000. The coarse tier (prefix T/Q) is emitted alongside the per-row CIGAR fine tier by default so whole-genome synteny views can auto-switch to it; pass --no-coarse to omit it.',
|
|
24
|
+
},
|
|
25
|
+
'no-coarse': {
|
|
26
|
+
type: 'boolean',
|
|
27
|
+
description: 'Do not emit the coarse tier; write only the per-row CIGAR fine tier.',
|
|
28
|
+
},
|
|
29
|
+
threads: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Compression threads for bgzip. Defaults to 4. Raise it on a machine with cores to spare, or set 1 to leave the rest of the machine alone.',
|
|
32
|
+
},
|
|
19
33
|
};
|
|
20
34
|
const { values: flags, positionals } = parseArgs({
|
|
21
35
|
args,
|
|
@@ -24,14 +38,33 @@ export async function run(args) {
|
|
|
24
38
|
});
|
|
25
39
|
const description = 'creates pairwise indexed PAF (PIF), with bgzip and tabix';
|
|
26
40
|
const examples = [
|
|
27
|
-
'
|
|
41
|
+
'# creates input.pif.gz and input.pif.gz.tbi in the same directory',
|
|
42
|
+
'$ jbrowse make-pif input.paf',
|
|
43
|
+
'',
|
|
44
|
+
'# specify the output file, also creates output.pif.gz.tbi',
|
|
45
|
+
'$ jbrowse make-pif input.paf --out output.pif.gz',
|
|
46
|
+
'',
|
|
47
|
+
'# use a CSI index for assemblies with chromosomes longer than ~512 Mb',
|
|
48
|
+
'$ jbrowse make-pif input.paf --csi',
|
|
49
|
+
'',
|
|
50
|
+
'# a looser coarse tier: runs within 50kb of the alignment, indels over 25kb kept',
|
|
51
|
+
'$ jbrowse make-pif input.paf --coarse 50000',
|
|
52
|
+
'',
|
|
53
|
+
'# emit only the per-row CIGAR fine tier, skipping the coarse tier',
|
|
54
|
+
'$ jbrowse make-pif input.paf --no-coarse',
|
|
28
55
|
'',
|
|
29
|
-
'
|
|
56
|
+
'# give bgzip more (or fewer) compression threads than the default 4',
|
|
57
|
+
'$ jbrowse make-pif input.paf --threads 8',
|
|
30
58
|
];
|
|
59
|
+
const notes = 'Use --csi for assemblies containing sequences longer than ~512 Mb. The ' +
|
|
60
|
+
'default TBI index cannot address coordinates beyond 2^29 (~536 Mb), so a ' +
|
|
61
|
+
'CSI index is required for large chromosomes (e.g. some plant and ' +
|
|
62
|
+
'amphibian genomes). Requires sh, sort, bgzip, and tabix on the PATH.';
|
|
31
63
|
if (flags.help) {
|
|
32
64
|
printHelp({
|
|
33
65
|
description,
|
|
34
66
|
examples,
|
|
67
|
+
notes,
|
|
35
68
|
usage: 'jbrowse make-pif <file> [options]',
|
|
36
69
|
options,
|
|
37
70
|
});
|
|
@@ -39,11 +72,95 @@ export async function run(args) {
|
|
|
39
72
|
}
|
|
40
73
|
const file = positionals[0];
|
|
41
74
|
validateFileArgument(file, 'make-pif', 'paf');
|
|
42
|
-
validateRequiredCommands(['sh', 'sort', '
|
|
43
|
-
const { out, csi = false } = flags;
|
|
75
|
+
validateRequiredCommands(['sh', 'sort', 'tabix', 'bgzip']);
|
|
76
|
+
const { out, csi = false, coarse, 'no-coarse': noCoarse = false, threads, } = flags;
|
|
44
77
|
const outputFile = getOutputFilename(file, out);
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
// --no-coarse used to silently win over an explicit --coarse, so a run asking
|
|
79
|
+
// for both wrote a file with no coarse tier and said nothing about it
|
|
80
|
+
if (noCoarse && coarse !== undefined) {
|
|
81
|
+
throw new Error('--coarse and --no-coarse are mutually exclusive');
|
|
82
|
+
}
|
|
83
|
+
const coarseGap = noCoarse
|
|
84
|
+
? undefined
|
|
85
|
+
: coarse === undefined
|
|
86
|
+
? DEFAULT_COARSE_GAP
|
|
87
|
+
: +coarse;
|
|
88
|
+
// a whole number because the header states it as an `i` tag, and positive
|
|
89
|
+
// because a coarse row without a fold means "one run within the bound"
|
|
90
|
+
if (coarseGap !== undefined &&
|
|
91
|
+
(!Number.isInteger(coarseGap) || coarseGap <= 0)) {
|
|
92
|
+
throw new Error(`Invalid --coarse value: ${coarse} (must be a positive whole number of bp)`);
|
|
93
|
+
}
|
|
94
|
+
const bgzipThreads = threads === undefined ? DEFAULT_BGZIP_THREADS : +threads;
|
|
95
|
+
if (!Number.isInteger(bgzipThreads) || bgzipThreads < 1) {
|
|
96
|
+
throw new Error(`Invalid --threads value: ${threads}`);
|
|
97
|
+
}
|
|
98
|
+
const indexExt = csi ? 'csi' : 'tbi';
|
|
99
|
+
const indexFile = `${outputFile}.${indexExt}`;
|
|
100
|
+
// The pipeline writes under a temporary name, renamed into place only once
|
|
101
|
+
// the pass, the pipeline and the row census have all succeeded: a truncated
|
|
102
|
+
// .paf.gz or a non-PAF input used to leave a valid, indexed PIF of whatever
|
|
103
|
+
// preceded the failure, which a build script reads as done.
|
|
104
|
+
const partFile = `${outputFile}.part`;
|
|
105
|
+
const partIndex = `${partFile}.${indexExt}`;
|
|
106
|
+
const child = spawnSortProcess(partFile, csi, bgzipThreads);
|
|
107
|
+
const closed = waitForProcessClose(child);
|
|
108
|
+
const pass = await createPIF(file, child.stdin, coarseGap).then(stats => ({ stats }), (error) => ({
|
|
109
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
110
|
+
}));
|
|
47
111
|
child.stdin.end();
|
|
48
|
-
|
|
112
|
+
// no SIGPIPE exemption here (unlike sort-gff/sort-bed): this pipeline's output
|
|
113
|
+
// is the .pif.gz file, so anything that killed it early left that file broken
|
|
114
|
+
const exit = await closed;
|
|
115
|
+
const discardPart = () => {
|
|
116
|
+
fs.rmSync(partFile, { force: true });
|
|
117
|
+
fs.rmSync(partIndex, { force: true });
|
|
118
|
+
};
|
|
119
|
+
if ('error' in pass) {
|
|
120
|
+
discardPart();
|
|
121
|
+
throw pass.error;
|
|
122
|
+
}
|
|
123
|
+
if (exit.code !== 0) {
|
|
124
|
+
discardPart();
|
|
125
|
+
throw new Error(`PIF sort/index pipeline exited with ${describeExit(exit)}`);
|
|
126
|
+
}
|
|
127
|
+
const { stats } = pass;
|
|
128
|
+
const { samples, rows, skipped } = stats;
|
|
129
|
+
if (rows === 0) {
|
|
130
|
+
discardPart();
|
|
131
|
+
throw new Error(`No valid PAF rows found in ${file ?? 'stdin'} (${skipped} line(s) had fewer than the 12 mandatory PAF columns). Is this a PAF file?`);
|
|
132
|
+
}
|
|
133
|
+
fs.renameSync(partFile, outputFile);
|
|
134
|
+
fs.renameSync(partIndex, indexFile);
|
|
135
|
+
if (skipped > 0) {
|
|
136
|
+
console.warn(`Warning: skipped ${skipped} of ${rows + skipped} line(s) with fewer than the 12 mandatory PAF columns`);
|
|
137
|
+
}
|
|
138
|
+
// An all-vs-all PAF that does not state every pair indexes perfectly well and
|
|
139
|
+
// then draws an empty synteny band for each pair the aligner never emitted,
|
|
140
|
+
// which reads as "no homology here" rather than as "never computed". This is
|
|
141
|
+
// the moment someone is looking at the file, and the census it needs was
|
|
142
|
+
// already taken on the pass above.
|
|
143
|
+
const missing = missingPairs(stats);
|
|
144
|
+
if (missing.length > 0) {
|
|
145
|
+
const shown = missing
|
|
146
|
+
.slice(0, 5)
|
|
147
|
+
.map(([a, b]) => `${a}<->${b}`)
|
|
148
|
+
.join(', ');
|
|
149
|
+
const rest = missing.length - Math.min(missing.length, 5);
|
|
150
|
+
console.warn(`Warning: this file has ${samples.size} samples but states only ` +
|
|
151
|
+
`${(samples.size * (samples.size - 1)) / 2 - missing.length} of their ` +
|
|
152
|
+
`${(samples.size * (samples.size - 1)) / 2} pairs. Synteny bands for the ` +
|
|
153
|
+
`missing pairs (${shown}${rest > 0 ? `, and ${rest} more` : ''}) will draw ` +
|
|
154
|
+
`empty. That is what a reference-anchored alignment looks like — only ` +
|
|
155
|
+
`pairs involving the reference are stated. Order the synteny rows so the ` +
|
|
156
|
+
`reference sits between the others, use a complete all-vs-all if the ` +
|
|
157
|
+
`project publishes one, or view a larger cohort as a multiple alignment ` +
|
|
158
|
+
`(MAF) rather than a stack of pairwise bands.`);
|
|
159
|
+
}
|
|
160
|
+
const nextCommand = samples.size > 0
|
|
161
|
+
? 'Next, add it as an all-vs-all synteny track (PanSN names detected):\n' +
|
|
162
|
+
` jbrowse add-track ${outputFile} --adapterType AllVsAllIndexedPAFAdapter -a ${[...samples].sort().join(',')} --load copy`
|
|
163
|
+
: 'Next, add it as a synteny track (set -a to your query,target assembly names):\n' +
|
|
164
|
+
` jbrowse add-track ${outputFile} -a query,target --load copy`;
|
|
165
|
+
console.log(`Created ${outputFile} and ${indexFile}\n\n${nextCommand}`);
|
|
49
166
|
}
|