@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.
@@ -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 { DEFAULT_COARSE_SPLIT_GAP, createPIF, getOutputFilename, missingPairs, spawnSortProcess, } from './pif-generator.js';
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: 'Minimum insertion/deletion length (bp) at which a coarse-tier row is split into multiple pieces so each row stays tight — 0 emits an unsplit coarse tier. Defaults to 10000. The no-CIGAR 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
+ 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 no-CIGAR tier; write only the per-row CIGAR fine tier.',
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
- '# emit an unsplit coarse tier (alongside the fine tier)',
46
- '$ jbrowse make-pif input.paf --coarse 0',
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 coarseSplitGap = noCoarse
83
+ const coarseGap = noCoarse
76
84
  ? undefined
77
85
  : coarse === undefined
78
- ? DEFAULT_COARSE_SPLIT_GAP
86
+ ? DEFAULT_COARSE_GAP
79
87
  : +coarse;
80
- if (coarseSplitGap !== undefined &&
81
- (!Number.isFinite(coarseSplitGap) || coarseSplitGap < 0)) {
82
- throw new Error(`Invalid --coarse value: ${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)`);
83
93
  }
84
- const child = spawnSortProcess(outputFile, csi);
85
- const stdin = child.stdin;
86
- // end stdin even if createPIF throws, otherwise the spawned sort/index child
87
- // is left running with an open stdin
88
- const stats = await createPIF(file, stdin, coarseSplitGap).finally(() => {
89
- stdin.end();
90
- });
91
- const { samples, rows, skipped } = stats;
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 waitForProcessClose(child);
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
- // A file that yielded nothing is almost always the wrong file, not an empty
99
- // alignment: without this the command wrote a valid, indexed, empty PIF and
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 AllVsAllIndexedPAFAdapter -a ${[...samples].sort().join(',')} --load copy`
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, pafIdentity, swapIndelCigar, } from '@jbrowse/cigar-utils';
8
- import { splitCigarOnLargeGaps } from './cigar-utils.js';
9
- // Default split gap (bp) for the coarse tier. A row is broken into multiple
10
- // coarse pieces wherever a CIGAR insertion/deletion is at least this long, so
11
- // each coarse row's bounding box stays tight. 10kb matches the adapter's
12
- // default coarseBpPerPxThreshold (~1px at the zoom where the coarse tier is
13
- // served), so smaller gaps that would be sub-pixel there don't fragment rows.
14
- export const DEFAULT_COARSE_SPLIT_GAP = 10_000;
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
- function coarsePieces({ cigar, strand, tstart, tend, qstart, qend, numMatches, blockLen, splitGap, }) {
51
- const whole = { tstart, tend, qstart, qend, numMatches, blockLen };
52
- const split = cigar && splitGap > 0
53
- ? splitCigarOnLargeGaps({
54
- cigar,
55
- strand,
56
- tstart,
57
- tend,
58
- qstart,
59
- qend,
60
- splitGap,
61
- })
62
- : undefined;
63
- // The walk's reconstruction is taken only when it CLOSED on the row's own far
64
- // corner (see `CigarSplit.closed`) — otherwise the columns are what the fine
65
- // tier draws and the coarse row must not drift off them.
66
- //
67
- // Closure rather than a piece count: `segments.length < 2` read as "nothing to
68
- // split on", but a large gap at either END also yields a single piece, a
69
- // genuine tightening that was being thrown away for the loose box. It is the
70
- // ordinary case below the default `--coarse`. The count also never noticed a
71
- // MULTI-piece walk that failed to close, which is the case it was written for.
72
- return split?.closed && split.segments.length > 0
73
- ? split.segments.map(seg => ({
74
- ...seg,
75
- numMatches: blockLen > 0 ? Math.round((numMatches * seg.blockLen) / blockLen) : 0,
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(line, coarseSplitGap, stats) {
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
- const { tags, cigarIdx } = foldCsIntoCg(rest);
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 (coarseSplitGap === undefined) {
155
+ if (coarseGap === undefined) {
159
156
  return fineRows;
160
157
  }
161
- const numMatches = +tags[0];
162
- const blockLen = +tags[1];
163
- const mapq = tags[2];
164
- // Every optional tag except the alignment string itself rides along, so a
165
- // click on a coarse ribbon shows the same attributes as the same alignment
166
- // does zoomed in (rustybam's tags, minimap2's tp/cm/s1, odgi's id). Only the
167
- // CIGAR is dropped — dropping it is the entire point of the tier — and de:f:,
168
- // which is rewritten below. No `cs:Z:` can reach here: it was folded into the
169
- // cg above, since a PIF row carries one alignment string.
170
- const passthrough = tags
171
- .slice(3)
172
- .filter(f => !f.startsWith('cg:Z:') && !f.startsWith('de:f:'));
173
- const de = coarseDivergence(tags, numMatches, blockLen);
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
- coarsePieces({
176
- cigar,
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(coarseSplitGap, stats) {
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, coarseSplitGap, stats))
197
+ .map(l => processLine(l, coarseGap, stats))
241
198
  .join(''));
242
199
  },
243
200
  flush(callback) {
244
- callback(null, tail ? processLine(tail, coarseSplitGap, stats) : '');
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, coarseSplitGap) {
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(coarseSplitGap, stats);
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
- export function spawnSortProcess(outputFile, useCsi) {
271
- const sortCmd = `sort -t"\`printf '\\t'\`" -k1,1 -k3,3n`;
272
- const bgzipCommand = `bgzip > "$1"`;
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
+ }
@@ -12,10 +12,6 @@ export function createTrixAdapter(name, assemblyNames, idSuffix = 'index') {
12
12
  uri: uris.ixx,
13
13
  locationType: 'UriLocation',
14
14
  },
15
- metaFilePath: {
16
- uri: uris.meta,
17
- locationType: 'UriLocation',
18
- },
19
15
  assemblyNames,
20
16
  };
21
17
  }