@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.
@@ -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
+ }