@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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { adapterTypesToTrackTypeMap, formats, matchFormat, resolveIndexType, trackTypeForAdapter, } from '@jbrowse/add-track-core';
|
|
3
|
+
import { isURL } from '../../types/common.js';
|
|
4
|
+
import { densitySidecarPath } from '../shared/density.js';
|
|
3
5
|
function hasLocField(spec) {
|
|
4
6
|
return 'locField' in spec;
|
|
5
7
|
}
|
|
@@ -51,6 +53,53 @@ export function siblingSidecar(location, suffix) {
|
|
|
51
53
|
];
|
|
52
54
|
return candidates.find(c => fs.existsSync(c)) ?? candidates[0];
|
|
53
55
|
}
|
|
56
|
+
// The feature adapters whose schema spreads
|
|
57
|
+
// `densityAdapterConfigSchemaFields`, so a `densityAdapter` written onto one
|
|
58
|
+
// resolves to a slot rather than being dropped. Change one, change the other.
|
|
59
|
+
const densityAdapterTypes = new Set([
|
|
60
|
+
'BamAdapter',
|
|
61
|
+
'CramAdapter',
|
|
62
|
+
'HtsgetBamAdapter',
|
|
63
|
+
'Gff3TabixAdapter',
|
|
64
|
+
'GtfTabixAdapter',
|
|
65
|
+
'BedTabixAdapter',
|
|
66
|
+
'BigBedAdapter',
|
|
67
|
+
'VcfTabixAdapter',
|
|
68
|
+
'SplitVcfTabixAdapter',
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* The adapter with its density sidecar attached, and the local file to load
|
|
72
|
+
* beside the config when there is one.
|
|
73
|
+
*
|
|
74
|
+
* An explicit `--density` is taken as given, since a URL cannot be probed;
|
|
75
|
+
* otherwise the conventional `<file>.density.bw` counts only when it is
|
|
76
|
+
* actually there, the way `siblingSidecar` probes an index.
|
|
77
|
+
*/
|
|
78
|
+
export function withDensityAdapter({ adapter, location, density, makeLocation, }) {
|
|
79
|
+
const supported = densityAdapterTypes.has(adapter.type);
|
|
80
|
+
if (density !== undefined && !supported) {
|
|
81
|
+
throw new Error(`--density has no slot on ${adapter.type}. The adapters carrying a density sidecar are: ${[...densityAdapterTypes].join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
const probed = location !== undefined && fs.existsSync(densitySidecarPath(location))
|
|
84
|
+
? densitySidecarPath(location)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (probed !== undefined && !supported) {
|
|
87
|
+
console.warn(`Warning: ${probed} sits beside the track file but ${adapter.type} has no densityAdapter slot, so it is not attached. The adapters carrying one are indexed: bgzip and tabix the file, then add it.`);
|
|
88
|
+
}
|
|
89
|
+
const file = density === undefined ? (supported ? probed : undefined) : density;
|
|
90
|
+
return file === undefined
|
|
91
|
+
? { adapter }
|
|
92
|
+
: {
|
|
93
|
+
adapter: {
|
|
94
|
+
...adapter,
|
|
95
|
+
densityAdapter: {
|
|
96
|
+
type: 'BigWigAdapter',
|
|
97
|
+
bigWigLocation: makeLocation(file),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
file: isURL(file) ? undefined : file,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
54
103
|
// builds the adapter object and the list of source files for a spec in one
|
|
55
104
|
// place, so the config it writes and the files add-track copies can never drift
|
|
56
105
|
function buildFromSpec(spec, { location, index, bed1, bed2, makeLocation }) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { printHelp, readConfigFile, resolveConfigPath } from '../utils.js';
|
|
4
|
-
import { guessTrack, guessTrackType, makeLocationProtocol, } from './add-track-utils/adapter-utils.js';
|
|
4
|
+
import { guessTrack, guessTrackType, makeLocationProtocol, withDensityAdapter, } from './add-track-utils/adapter-utils.js';
|
|
5
5
|
import { loadFiles } from './add-track-utils/file-operations.js';
|
|
6
6
|
import { buildMultiWiggle } from './add-track-utils/multiwig.js';
|
|
7
7
|
import { addSyntenyAssemblyNames, buildTrackConfig, mapLocationForFiles, mergeDisplayDefaults, } from './add-track-utils/track-config.js';
|
|
@@ -104,6 +104,10 @@ export async function run(args) {
|
|
|
104
104
|
type: 'string',
|
|
105
105
|
description: 'Used only for mcscan anchors/simpleAnchors types',
|
|
106
106
|
},
|
|
107
|
+
density: {
|
|
108
|
+
type: 'string',
|
|
109
|
+
description: "Path or URL of a features-per-bin bigWig (jbrowse make-density) to attach as the adapter's densityAdapter. A local <file>.density.bw beside the track file is attached without this flag",
|
|
110
|
+
},
|
|
107
111
|
};
|
|
108
112
|
const { values: flags, positionals } = parseArgs({
|
|
109
113
|
args,
|
|
@@ -134,6 +138,13 @@ export async function run(args) {
|
|
|
134
138
|
'BigWig files/URLs, or a .json file with an array of BigWig locations or ' +
|
|
135
139
|
'subadapter objects (each carrying its own name/color/group). With --load, ' +
|
|
136
140
|
'local list entries are copied like any other track file.\n\n' +
|
|
141
|
+
'--density attaches a features-per-bin bigWig (jbrowse make-density) as the ' +
|
|
142
|
+
"adapter's densityAdapter, the band a display draws where the region is too " +
|
|
143
|
+
'large to fetch features. A local <file>.density.bw beside the track file is ' +
|
|
144
|
+
'attached without the flag; a URL cannot be probed, so a remote sidecar needs ' +
|
|
145
|
+
'it. The adapters carrying the slot are BamAdapter, CramAdapter, ' +
|
|
146
|
+
'HtsgetBamAdapter, Gff3TabixAdapter, GtfTabixAdapter, BedTabixAdapter, ' +
|
|
147
|
+
'BigBedAdapter, VcfTabixAdapter and SplitVcfTabixAdapter.\n\n' +
|
|
137
148
|
'For pairwise synteny adapters (PAF/Delta/Chain) --assemblyNames is ' +
|
|
138
149
|
'query,target — the reverse of the minimap2/nucmer input order. For the ' +
|
|
139
150
|
'all-vs-all adapters (AllVsAllPAFAdapter, AllVsAllIndexedPAFAdapter) it is ' +
|
|
@@ -166,6 +177,9 @@ export async function run(args) {
|
|
|
166
177
|
'',
|
|
167
178
|
'# ...or from a sources.json carrying per-row name/color for each BigWig',
|
|
168
179
|
'$ jbrowse add-track --multiwig sources.json --name "CATlas ATAC"',
|
|
180
|
+
'',
|
|
181
|
+
'# attach a remote density sidecar (a local genes.gff3.density.bw needs no flag)',
|
|
182
|
+
'$ jbrowse add-track https://mywebsite.com/genes.gff3.gz --density https://mywebsite.com/genes.gff3.density.bw',
|
|
169
183
|
];
|
|
170
184
|
if (flags.help) {
|
|
171
185
|
printHelp({
|
|
@@ -187,7 +201,7 @@ export async function run(args) {
|
|
|
187
201
|
const mapLocation = (p) => wrap(mapLocationForFiles(p, load, subDir));
|
|
188
202
|
// build the adapter (and the set of files to load) up front, so the track-arg
|
|
189
203
|
// and load validation runs before we touch the config on disk
|
|
190
|
-
const { adapter, files, trackType, trackId } = multiwig
|
|
204
|
+
const { adapter: builtAdapter, files, trackType, trackId, } = multiwig
|
|
191
205
|
? buildMultiWiggleTrack({
|
|
192
206
|
sources: multiwig,
|
|
193
207
|
mapLocation,
|
|
@@ -207,6 +221,15 @@ export async function run(args) {
|
|
|
207
221
|
trackType: flags.trackType,
|
|
208
222
|
trackId: flags.trackId,
|
|
209
223
|
});
|
|
224
|
+
const { adapter, file: densityFile } = withDensityAdapter({
|
|
225
|
+
adapter: builtAdapter,
|
|
226
|
+
location,
|
|
227
|
+
density: flags.density,
|
|
228
|
+
makeLocation: mapLocation,
|
|
229
|
+
});
|
|
230
|
+
if (densityFile !== undefined && !load) {
|
|
231
|
+
throw new Error(`--density names a local file (${densityFile}); pass --load to say how it is placed beside the config`);
|
|
232
|
+
}
|
|
210
233
|
const baseConfigObj = config ? parseConfigFlag(config) : undefined;
|
|
211
234
|
const displayDefaults = mergeDisplayDefaults({
|
|
212
235
|
configObj: baseConfigObj,
|
|
@@ -245,7 +268,7 @@ export async function run(args) {
|
|
|
245
268
|
});
|
|
246
269
|
const updatedConfig = { ...configContents, tracks };
|
|
247
270
|
await loadFiles({
|
|
248
|
-
files,
|
|
271
|
+
files: [...files, densityFile],
|
|
249
272
|
destDir: configDir,
|
|
250
273
|
mode: load,
|
|
251
274
|
subDir,
|
|
@@ -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,8 +1,9 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import { parseArgs } from 'node:util';
|
|
2
3
|
import { printHelp } from '../../utils.js';
|
|
3
4
|
import { describeExit, waitForProcessClose } from '../process-utils.js';
|
|
4
5
|
import { validateFileArgument, validateRequiredCommands, } from '../shared/validators.js';
|
|
5
|
-
import {
|
|
6
|
+
import { DEFAULT_BGZIP_THREADS, DEFAULT_COARSE_GAP, createPIF, getOutputFilename, missingPairs, spawnSortProcess, } from './pif-generator.js';
|
|
6
7
|
export async function run(args) {
|
|
7
8
|
const options = {
|
|
8
9
|
help: {
|
|
@@ -19,11 +20,15 @@ export async function run(args) {
|
|
|
19
20
|
},
|
|
20
21
|
coarse: {
|
|
21
22
|
type: 'string',
|
|
22
|
-
description: '
|
|
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.',
|
|
23
24
|
},
|
|
24
25
|
'no-coarse': {
|
|
25
26
|
type: 'boolean',
|
|
26
|
-
description: 'Do not emit the coarse
|
|
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.',
|
|
27
32
|
},
|
|
28
33
|
};
|
|
29
34
|
const { values: flags, positionals } = parseArgs({
|
|
@@ -42,11 +47,14 @@ export async function run(args) {
|
|
|
42
47
|
'# use a CSI index for assemblies with chromosomes longer than ~512 Mb',
|
|
43
48
|
'$ jbrowse make-pif input.paf --csi',
|
|
44
49
|
'',
|
|
45
|
-
'#
|
|
46
|
-
'$ jbrowse make-pif input.paf --coarse
|
|
50
|
+
'# a looser coarse tier: runs within 50kb of the alignment, indels over 25kb kept',
|
|
51
|
+
'$ jbrowse make-pif input.paf --coarse 50000',
|
|
47
52
|
'',
|
|
48
53
|
'# emit only the per-row CIGAR fine tier, skipping the coarse tier',
|
|
49
54
|
'$ jbrowse make-pif input.paf --no-coarse',
|
|
55
|
+
'',
|
|
56
|
+
'# give bgzip more (or fewer) compression threads than the default 4',
|
|
57
|
+
'$ jbrowse make-pif input.paf --threads 8',
|
|
50
58
|
];
|
|
51
59
|
const notes = 'Use --csi for assemblies containing sequences longer than ~512 Mb. The ' +
|
|
52
60
|
'default TBI index cannot address coordinates beyond 2^29 (~536 Mb), so a ' +
|
|
@@ -65,42 +73,65 @@ export async function run(args) {
|
|
|
65
73
|
const file = positionals[0];
|
|
66
74
|
validateFileArgument(file, 'make-pif', 'paf');
|
|
67
75
|
validateRequiredCommands(['sh', 'sort', 'tabix', 'bgzip']);
|
|
68
|
-
const { out, csi = false, coarse, 'no-coarse': noCoarse = false } = flags;
|
|
76
|
+
const { out, csi = false, coarse, 'no-coarse': noCoarse = false, threads, } = flags;
|
|
69
77
|
const outputFile = getOutputFilename(file, out);
|
|
70
78
|
// --no-coarse used to silently win over an explicit --coarse, so a run asking
|
|
71
79
|
// for both wrote a file with no coarse tier and said nothing about it
|
|
72
80
|
if (noCoarse && coarse !== undefined) {
|
|
73
81
|
throw new Error('--coarse and --no-coarse are mutually exclusive');
|
|
74
82
|
}
|
|
75
|
-
const
|
|
83
|
+
const coarseGap = noCoarse
|
|
76
84
|
? undefined
|
|
77
85
|
: coarse === undefined
|
|
78
|
-
?
|
|
86
|
+
? DEFAULT_COARSE_GAP
|
|
79
87
|
: +coarse;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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)`);
|
|
83
93
|
}
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}));
|
|
111
|
+
child.stdin.end();
|
|
92
112
|
// no SIGPIPE exemption here (unlike sort-gff/sort-bed): this pipeline's output
|
|
93
113
|
// is the .pif.gz file, so anything that killed it early left that file broken
|
|
94
|
-
const exit = await
|
|
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
|
+
}
|
|
95
123
|
if (exit.code !== 0) {
|
|
124
|
+
discardPart();
|
|
96
125
|
throw new Error(`PIF sort/index pipeline exited with ${describeExit(exit)}`);
|
|
97
126
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// printed the add-track suggestion for it
|
|
127
|
+
const { stats } = pass;
|
|
128
|
+
const { samples, rows, skipped } = stats;
|
|
101
129
|
if (rows === 0) {
|
|
130
|
+
discardPart();
|
|
102
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?`);
|
|
103
132
|
}
|
|
133
|
+
fs.renameSync(partFile, outputFile);
|
|
134
|
+
fs.renameSync(partIndex, indexFile);
|
|
104
135
|
if (skipped > 0) {
|
|
105
136
|
console.warn(`Warning: skipped ${skipped} of ${rows + skipped} line(s) with fewer than the 12 mandatory PAF columns`);
|
|
106
137
|
}
|
|
@@ -126,7 +157,6 @@ export async function run(args) {
|
|
|
126
157
|
`project publishes one, or view a larger cohort as a multiple alignment ` +
|
|
127
158
|
`(MAF) rather than a stack of pairwise bands.`);
|
|
128
159
|
}
|
|
129
|
-
const indexFile = `${outputFile}.${csi ? 'csi' : 'tbi'}`;
|
|
130
160
|
const nextCommand = samples.size > 0
|
|
131
161
|
? 'Next, add it as an all-vs-all synteny track (PanSN names detected):\n' +
|
|
132
162
|
` jbrowse add-track ${outputFile} --adapterType AllVsAllIndexedPAFAdapter -a ${[...samples].sort().join(',')} --load copy`
|