@jbrowse/cli 5.0.0-beta.1 → 5.0.0-beta.3
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 +115 -26
- package/dist/commands/add-track-utils/adapter-utils.js +49 -0
- package/dist/commands/add-track.js +29 -6
- package/dist/commands/admin-server/index.js +13 -5
- package/dist/commands/admin-server/utils.js +8 -6
- 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 +55 -25
- package/dist/commands/make-pif/pif-generator.js +105 -133
- package/dist/commands/shared/density.js +12 -0
- package/dist/commands/text-index/adapter-utils.js +0 -4
- package/dist/commands/validate/configManifest.generated.js +408 -357
- 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 +5 -9
- package/bin/run +0 -4
- package/bundle/index.js +0 -1569
- package/dist/commands/make-pif/cigar-utils.js +0 -67
|
@@ -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,10 +157,9 @@ 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
|
-
` jbrowse add-track ${outputFile} --adapterType
|
|
162
|
+
` jbrowse add-track ${outputFile} --adapterType MultiGenomeIndexedPAFAdapter -a ${[...samples].sort().join(',')} --load copy`
|
|
133
163
|
: 'Next, add it as a synteny track (set -a to your query,target assembly names):\n' +
|
|
134
164
|
` jbrowse add-track ${outputFile} -a query,target --load copy`;
|
|
135
165
|
console.log(`Created ${outputFile} and ${indexFile}\n\n${nextCommand}`);
|
|
@@ -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
|
+
}
|