Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 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 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 | 2x 2x 2x 2x 15807811x 15696586x 15477204x 15477204x 15477204x 15477204x 271x 271x 271x 271x 157x 157x 1x 1x 1x 1x 15477362x 15477362x 15405020x 15405020x 15477362x 44254x 44254x 44254x 656966x 656966x 656966x 238514x 238514x 238514x 2x 2x 2x 2x 30088146x 30088146x 30088146x 2x 7500x 7499x 7499x 7499x 7500x 2x 2x 596x 596x 2x 19x 19x 2174x 2174x 2174x 2174x 2168x 624x 624x 2x 527x 527x 527x 527x 527x 527x 527x 527x 527x 2174x 2174x 527x 527x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 15041936x 15041936x 15041936x 15041936x 15041936x 81x 81x 81x 85x 85x 85x 85x 85x 85x 85x 85x 85x 170x 170x 170x 170x 170x 87x 87x 87x 170x 170x 85x 85x 85x 4595x 4595x 4595x 4595x 4595x 8175x 8175x 8175x 403x 403x 403x 403x 403x 8175x 4595x 4595x 85x 85x 23x 23x 23x 23x 23x 23x 33x 33x 23x 2390x 23x 23x 23x 23x 23x 46x 46x 1x 1x 1x 1x 1x 1x 1x 26x 7x 7x 23x 2364x 23x 23x 23x 23x 1633x 1633x 1633x 1633x 1633x 23x 23x 23x 23x 23x 1x 1x 1x 1x 1x 2x 2x 2x 2x 1x 23x 23x 36x 36x 36x 36x 36x 36x 36x 36x 36x 308x 308x 308x 837x 837x 837x 837x 234x 603x 308x 308x 308x 308x 36x 36x 208118x 208118x 208118x 28x 28x 28x 28x 28x 28x 208118x 208118x 208118x 208118x 208118x 208118x 208118x 208118x 208118x 208118x 208118x 208118x 28x 28x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 4x 4x 4x 2x 4x 4x 4x 4x 4x 23602x 23602x 23602x 23602x 23602x 23602x 4x 4x 4x 23602x 23602x 4x 2x 2x 2x 252x 252x 252x 2x 1x 1x 1x 1x 1x 1x 2x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 1x 1x 253x 253x 253x 81x 253x 85x 253x 23x 253x 36x 253x 28x 253x 253x 2x 2x 2x 2x 2x 2x | /**
* Every analysis output, as a table anyone can open.
*
* TSV is offered beside every other export §18.7 names, and of all of them it is
* the important one: **it is what makes a number in a published figure checkable
* by the person reading the figure.** A bigWig is what a genome browser opens, a
* BED is what bedtools takes, a PDF is what goes in the manuscript — but none of
* those lets a reader put the figure's third boundary next to the number it was
* drawn from and see that they agree. A tab-separated table does, in a
* spreadsheet, in `awk`, in R, on a machine that has never heard of this app.
*
* That claim only holds if the file says where it came from, so every one of
* them opens with the provenance §18.4 requires — task, version, every
* parameter, the framing, the file, the build. The lines are `#`-prefixed for
* exactly the reason `annotations/export.ts` gives for the same line above a BED:
* this app's own parsers skip a `#` line and so does bedtools, so the provenance
* costs the file nothing and travels wherever the file does.
*
* Two rules run through the whole writer and both are about a reader downstream
* rather than about us:
*
* - **A missing value is an empty cell, never the text `NaN`.** Every reader
* worth having treats an empty cell as missing; most treat `NaN` in a numeric
* column as a parse failure, and the ones that do not turn it into zero. The
* `NaN`-means-no-value convention this codebase keeps end to end (§18.5) is
* only preserved across the file boundary by writing nothing at all.
* - **No exponent notation.** These numbers are read by a human in a
* spreadsheet, and `1.234e-5` in a column of contact counts is a cell that gets
* sorted as text. At most six significant digits, always in plain decimal.
*/
import type {
CurveOutput,
IntervalsOutput,
MatrixOutput,
ParamValue,
ProfileOutput,
Provenance,
TableOutput,
TaskOutput,
} from '../types'
import type { FeatureSet, PairSet } from '../../annotations/types'
import { exportBaseName } from '../../ui/export'
/** Six is what survives a float32 round trip and still fits a spreadsheet cell. */
const SIGNIFICANT_DIGITS = 6
/** `toFixed` throws past a hundred places, and nothing here needs one. */
const MAX_DECIMALS = 100
// ---------------------------------------------------------------------------
// Cells
// ---------------------------------------------------------------------------
/**
* One number, as a human reads it.
*
* Non-finite goes out empty — `NaN` because it means *no value here*, and the
* infinities because a ratio whose denominator was zero is not a measurement
* either, and writing `Infinity` into a numeric column breaks the same readers
* `NaN` does.
*
* An integer keeps no decimal point: a bin index, a feature count and a base
* pair are whole things, and `120000.000000` invites the reader to wonder what
* the zeroes are hiding. Everything else is rounded to
* {@link SIGNIFICANT_DIGITS} and printed fixed-point, so the column sorts as a
* number in every tool that opens it.
*/
/**
* One sequence's intervals — the shape `BigWigSpans` and `BedGraphSpans` take.
*
* Structural rather than imported, so this module goes on depending on nothing
* but its own types: the three writers are handed one object by one caller, and
* what makes them agree is that caller rather than a shared declaration.
*/
export interface TsvSpans {
contig: string
starts: ArrayLike<number>
ends: ArrayLike<number>
values: ArrayLike<number>
}
export function tsvNumber(value: number): string {
if (!Number.isFinite(value)) return ''
// `String` reaches for the exponent at 1e21 even for whole numbers, so the
// integer case is spelled out rather than left to the default conversion.
if (Number.isInteger(value)) return Math.abs(value) < 1e21 ? String(value) : value.toFixed(0)
const magnitude = Math.floor(Math.log10(Math.abs(value)))
const decimals = Math.min(MAX_DECIMALS, Math.max(0, SIGNIFICANT_DIGITS - 1 - magnitude))
return trimZeros(value.toFixed(decimals))
}
/**
* A number to a stated number of places — a table column, a printed statistic.
*
* `small` is a column's `scientific`: a value too small for its places is
* written with an exponent rather than as zero, as the drawer draws it. A P of
* 10⁻²³ written `0` is a claim the test never made, and a script reading the
* file has no way to tell it from a P that really was.
*/
function fixed(value: number, digits: number | undefined, small = false): string {
if (!Number.isFinite(value)) return ''
if (digits === undefined) return tsvNumber(Math.round(value))
if (small && value !== 0 && Math.abs(value) < 10 ** -digits) return withExponent(value)
return trimZeros(value.toFixed(Math.min(MAX_DECIMALS, Math.max(0, digits))))
}
/** `6.53e-23`: the drawer's spelling, at the precision a file keeps. */
function withExponent(value: number): string {
const [mantissa, exponent] = value.toExponential(SIGNIFICANT_DIGITS - 1).split('e')
return `${trimZeros(mantissa)}e${exponent}`
}
/**
* `1.500000` is six digits of nothing; `1.5` is the same number.
*
* The trailing zeros, and the point if nothing is left after it — what
* `/\.?0+$/` removes, scanned for by hand: this runs on every number of every
* table, and a heatmap is millions of them.
*/
function trimZeros(text: string): string {
if (text.indexOf('.') < 0) return text
let end = text.length
while (text.charCodeAt(end - 1) === 48) end--
if (end === text.length) return text
if (text.charCodeAt(end - 1) === 46) end--
return text.slice(0, end)
}
/**
* Text, with the two characters that would end the cell or the row taken out.
*
* A GTF attribute string or a feature name can contain anything, and a tab
* inside a cell does not corrupt the file visibly — it shifts every column after
* it by one, silently, for that row only. Quoting would be the other answer and
* is worse here: TSV has no agreed quoting rule, so half the readers would show
* the quotes.
*/
function cell(text: string): string {
return text.replace(/[\t\r\n]+/g, ' ')
}
function row(cells: readonly string[]): string {
return cells.join('\t')
}
/** Rows a {@link RowBlocks} joins at a time. */
const ROWS_PER_BLOCK = 4096
/**
* Rows gathered into pieces of a file a block at a time.
*
* A profile is a row per bin of the file — a quarter of a million at 10 kb on a
* mammal, for each of a task's tracks and in each of two formats — and held as
* rows to the end, every one of them lived long enough for the collector to
* move it out of its young space and then mark it again and again, which was a
* larger share of writing a coverage run than formatting its numbers. Joined a
* block at a time as they are made, the rows die young, and the block each four
* thousandth row completes is handed back to be written, each row ending in its
* `\n`, so the blocks end to end are the text.
*/
export class RowBlocks {
private readonly block: string[] = []
/** The row; and the block it completes, when it completes one. */
push(line: string): string | undefined {
this.block.push(line)
return this.block.length === ROWS_PER_BLOCK ? this.flush() : undefined
}
/** What is left, as one block, if anything is; call once the last row is pushed. */
flush(): string | undefined {
if (this.block.length === 0) return undefined
const text = `${this.block.join('\n')}\n`
this.block.length = 0
return text
}
}
/**
* Lines as pieces of a file: each with its newline, exactly one at the end.
*
* **Every writer here and in `bedgraph.ts` hands its file over in pieces, never
* as one string**: the browser makes a `Blob` of them and the shell writes them
* one after another. A string cannot be longer than 2²⁹ − 24 characters in V8, about
* 512 MB, and a file can: hg38 at 200 bp is fifteen million bins, and
* `composition --resolution 200` wrote the GC content's table, whose numbers
* are short enough to come in under, and then stopped at the skew's with
* *Invalid string length*. Made as they are asked for, the pieces of a profile
* are written as they are formatted, so the shell never holds a file's text at
* all.
*/
export function* pieces(lines: Iterable<string>): Generator<string> {
for (const line of lines) yield `${line}\n`
}
/** A file's pieces, end to end — for a test, or a file known to be small. */
export function textOf(parts: Iterable<string>): string {
return [...parts].join('')
}
// ---------------------------------------------------------------------------
// Provenance
// ---------------------------------------------------------------------------
/** One parameter value, in the syntax it was typed in. */
function paramText(value: ParamValue): string {
if (value === null) return ''
if (typeof value === 'number') return tsvNumber(value)
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (Array.isArray(value)) return value.join(',')
return String(value)
}
/**
* The header every export in this module opens with.
*
* Ordered from the widest fact to the narrowest — which build, which file, which
* task, which view, which settings — because that is the order somebody holding
* an unfamiliar file asks the questions in. The parameters come last and one per
* line rather than joined, since a task with a dozen of them would otherwise
* produce a line nothing can read without horizontal scrolling, and a parameter
* nobody reads is provenance that is present and useless.
*/
export function provenanceLines(provenance: Provenance): string[] {
const { framing, params } = provenance
const lines = [
`# chromosomix ${cell(provenance.app)}`,
`# created: ${cell(provenance.createdAt)}`,
`# file: ${cell(framing.uri)}`,
`# task: ${cell(provenance.task)} v${tsvNumber(provenance.taskVersion)}`,
`# framing: ${cell(framing.label)}`,
]
// Sorted by key, so two runs of one task diff line for line whichever order
// the form happened to build its parameter object in.
for (const key of Object.keys(params).sort()) {
lines.push(`# param ${cell(key)}: ${cell(paramText(params[key]))}`)
}
return lines
}
// ---------------------------------------------------------------------------
// The five kinds
// ---------------------------------------------------------------------------
/**
* `bin start end value` — one row per bin, whether or not the bin has a value.
*
* The empty rows are kept rather than skipped, which is the opposite of what the
* bigWig writer does with the same vector (§18.7). A bigWig is read by tools that
* understand a gap; a spreadsheet is read by a person, and a profile whose rows
* jump from bin 40 to bin 57 with no explanation looks like a truncated file
* rather than like sixteen filtered bins.
*/
function* profileToTsv(output: ProfileOutput): Generator<string> {
const [from] = output.extent
yield* pieces([
`# output: ${cell(output.label)}${output.unit === '' ? '' : ` (${cell(output.unit)})`}`,
`# binsize: ${tsvNumber(output.binsize)}`,
row(['bin', 'start', 'end', 'value']),
])
// One template a row rather than four cells joined: a profile is a row per
// bin of the file, a quarter of a million at 10 kb on a mammal, and the cells
// are the same strings either way.
const { binsize, values } = output
const rows = new RowBlocks()
for (let i = 0; i < values.length; i++) {
const bin = from + i
const start = bin * binsize
const block = rows.push(`${bin}\t${start}\t${start + binsize}\t${tsvNumber(values[i])}`)
if (block !== undefined) yield block
}
const rest = rows.flush()
if (rest !== undefined) yield rest
}
/**
* The series side by side, sharing one `x` column.
*
* Wide rather than long — `x, series, series_low, series_high` — because the
* question a curve export answers is *do these two lines differ here*, and that
* is a subtraction of two adjacent columns in a spreadsheet against a pivot
* table over a long file. A band contributes its two columns only where it
* exists, since a P(s) has none and three columns of copies of `y` would be a
* lie about what was computed — which is the reason `CurveSeries.low` is
* optional rather than filled with copies in the first place.
*
* Series of different lengths are padded with empty cells rather than truncated
* to the shortest: an expected curve out to a whole chromosome beside a metaplot
* over a flank is an ordinary pairing, and dropping the tail of the longer one
* would silently discard the part the reader zoomed in on. The `x` column is
* taken from the longest series so that no row lacks one — the series in a single
* output share their grid by construction, and where a future task breaks that
* assumption it wants two outputs rather than a second `x` column here.
*
* **Where the abscissa is a list, the `x` column holds its names** rather than
* the indices the figure plots them at (`CurveOutput.xTicks`). The whole claim
* of this file is that a reader can put a bar next to the number it was drawn
* from; a column of `0, 1, 2` beside two columns of fractions asks them to count
* bars on the picture to find out which sequence the third row is, which is the
* matrix writer's own argument for putting the ticks down the left edge.
*/
function curveToTsv(output: CurveOutput): string[] {
const lines = [
`# output: ${cell(output.label)}`,
`# x: ${cell(output.xLabel)} y: ${cell(output.yLabel)}`,
]
const header = [cell(output.xLabel) === '' ? 'x' : cell(output.xLabel)]
let rows = 0
let grid: Float64Array = new Float64Array(0)
for (const series of output.series) {
const name = cell(series.name)
header.push(name)
if (series.low !== undefined && series.high !== undefined)
header.push(`${name}_low`, `${name}_high`)
if (series.y.length > rows) {
rows = series.y.length
grid = series.x
}
if (series.n !== undefined) lines.push(`# n ${name}: ${tsvNumber(series.n)}`)
}
lines.push(row(header))
const names = output.xTicks
for (let i = 0; i < rows; i++) {
// The name where the list has one and the number otherwise, rather than a
// blank: a list shorter than the series it names is a bug in the task, and
// the honest thing to write for a point it does not reach is where the
// figure put it.
const at = names?.[i]
const cells = [
at !== undefined ? cell(at) : i < grid.length ? tsvNumber(grid[i]) : '',
]
for (const series of output.series) {
const present = i < series.y.length
cells.push(present ? tsvNumber(series.y[i]) : '')
if (series.low !== undefined && series.high !== undefined) {
cells.push(
present ? tsvNumber(series.low[i]) : '',
present ? tsvNumber(series.high[i]) : '',
)
}
}
lines.push(row(cells))
}
return lines
}
/**
* The grid as a grid, with its ticks along the top and down the left.
*
* The printed statistics — a P2LL, an AA/BB strength — go in `#` lines above the
* table rather than in cells beside it, because they are of the whole matrix and
* a spreadsheet that reads them as part of the rectangle gets a stray column in
* every calculation over it. Where an axis has no ticks the index is written
* instead: a bare grid of numbers with no way to say which row is which is a
* picture, not a table.
*
* **There are two kinds of tick and only one of them is a column heading.** A
* `MatrixOutput` whose tick list is as long as its axis is naming its cells —
* a genome per column on a MAG network, a track per column on a correlation —
* and each label belongs over its own column. A shorter list is a *position*
* along a continuous axis, drawn evenly spaced across the picture: the scaled
* pileup's units of the feature, where five labels describe forty-one columns.
* Written into the header those five would sit over the first five columns and
* the rest would be indices, which is a table stating something untrue about
* where the axis is. So a positional list goes into a `#` line saying what it
* is, and the columns keep their indices.
*/
function matrixToTsv(output: MatrixOutput): string[] {
const lines = [
`# output: ${cell(output.label)}`,
`# x: ${cell(output.xLabel)} y: ${cell(output.yLabel)}`,
]
for (const stat of output.stats ?? []) {
lines.push(`# ${cell(stat.label)}: ${fixed(stat.value, stat.digits ?? 4)}`)
}
const perCell = (ticks: readonly string[], count: number): boolean =>
ticks.length === count && count > 0
const placed = output.xTickAt
for (const [axis, ticks, count] of [
['x', output.xTicks, output.ncols],
['y', output.yTicks, output.nrows],
] as const) {
if (ticks.length === 0) continue
if (axis === 'x' && placed !== undefined && placed.length === ticks.length) {
// Placed ticks say where, in columns from the axis's left edge, left to
// right: `Start at 100` is the edge between columns 99 and 100.
const where = ticks
.map((tick, index) => ({ tick, column: placed[index] * count }))
.sort((a, b) => a.column - b.column)
.map((one) => `${cell(one.tick)} at ${fixed(one.column, 4)}`)
lines.push(`# ${axis} ticks, at a column edge (0 to ${count}): ${where.join(', ')}`)
continue
}
if (perCell(ticks, count)) continue
lines.push(`# ${axis} ticks, evenly spaced across the axis: ${ticks.map(cell).join(', ')}`)
}
const label = (ticks: readonly string[], index: number, count: number): string =>
perCell(ticks, count) ? cell(ticks[index]) : String(index)
const header = [cell(output.yLabel)]
for (let col = 0; col < output.ncols; col++) header.push(label(output.xTicks, col, output.ncols))
lines.push(row(header))
for (let r = 0; r < output.nrows; r++) {
const cells = [label(output.yTicks, r, output.nrows)]
for (let c = 0; c < output.ncols; c++)
cells.push(tsvNumber(output.values[r * output.ncols + c]))
lines.push(row(cells))
}
// The profiles drawn along the two edges (§18.8), as a **second block** under
// the matrix rather than as a row and a column of it. A pileup with track
// aggregates is two measurements in different units — contacts and signal —
// and a spreadsheet that read them as one rectangle would average them
// together. Left out entirely, which is what shipped, the lines a reader is
// looking at are the one thing in the figure with no numbers behind it.
const profiles = [
...(output.margins?.top ?? []).map((profile) => ['top', profile] as const),
...(output.margins?.right ?? []).map((profile) => ['right', profile] as const),
]
if (profiles.length > 0) {
const widest = Math.max(...profiles.map(([, profile]) => profile.values.length))
lines.push('')
lines.push('# aggregate profiles along the edges: top runs along x, right runs down y')
lines.push(row(['edge', 'track', ...Array.from({ length: widest }, (_, i) => String(i))]))
for (const [edge, profile] of profiles) {
const cells = [edge, cell(profile.name)]
for (const value of profile.values) cells.push(tsvNumber(value))
lines.push(row(cells))
}
}
return lines
}
/**
* The table as the drawer shows it, keyed rather than labelled.
*
* The header carries `TableColumn.key` and the labels go in a `#` line above it,
* because a label is prose that changes when somebody rewords the UI while a key
* is the stable name §18.2 requires of everything else — and a script somebody
* wrote against last month's export should keep working.
*/
function tableToTsv(output: TableOutput): string[] {
const lines = [
`# output: ${cell(output.label)}`,
// A start is written as it is held, 0-based, which is how a BED-shaped
// table is read back (§8.3) — and said to be, since the table it came from
// printed it 1-based (`TableColumn.coordinate`).
`# columns: ${output.columns
.map((column) => `${cell(column.label)}${column.coordinate === 'start' ? ' (0-based)' : ''}`)
.join(', ')}`,
row(output.columns.map((column) => cell(column.key))),
]
for (const record of output.rows) {
lines.push(
row(
output.columns.map((column) => {
const value = record[column.key]
if (value === undefined || value === null) return ''
if (typeof value === 'number')
return column.kind === 'number'
? fixed(value, column.digits, column.scientific === true)
: tsvNumber(value)
return cell(value)
}),
),
)
}
return lines
}
/** `+1`, `-1` and "the format said nothing", in the three characters BED uses. */
function strandChar(strand: number): string {
return strand > 0 ? '+' : strand < 0 ? '-' : '.'
}
/**
* Called intervals in their BED and BEDPE column order.
*
* `annotations/export.ts` is the real BED writer and stays the real BED writer:
* what it produces round-trips through this app's own parser and through
* bedtools, and an interval output is offered as that file first. This is the
* *tabular view of the same rows* — the same coordinates, with the header line
* and the provenance a spreadsheet wants and a BED consumer would choke on — and
* it is not a second implementation of the format nor a replacement for one.
*/
function intervalsToTsv(output: IntervalsOutput): string[] {
const lines = [`# output: ${cell(output.label)}`]
if (output.payload.kind === 'features') {
const set: FeatureSet = output.payload.data
lines.push(row(['chrom', 'start', 'end', 'name', 'score', 'strand', 'detail']))
for (let i = 0; i < set.length; i++) {
lines.push(
row([
cell(set.contigs[set.contig[i]] ?? ''),
String(set.start[i]),
String(set.end[i]),
cell(set.name[i] ?? ''),
tsvNumber(set.score[i]),
strandChar(set.strand[i]),
cell(set.detail[i] ?? ''),
]),
)
}
return lines
}
const set: PairSet = output.payload.data
lines.push(
row(['chrom1', 'start1', 'end1', 'chrom2', 'start2', 'end2', 'name', 'score', 'detail']),
)
for (let i = 0; i < set.length; i++) {
lines.push(
row([
cell(set.contigs[set.contig1[i]] ?? ''),
String(set.start1[i]),
String(set.end1[i]),
cell(set.contigs[set.contig2[i]] ?? ''),
String(set.start2[i]),
String(set.end2[i]),
cell(set.name[i] ?? ''),
tsvNumber(set.score[i]),
cell(set.detail[i] ?? ''),
]),
)
}
return lines
}
/**
* `chrom start end value` — a track that is on no grid, as a table.
*
* Its own function rather than a `ProfileOutput` with a made-up bin size,
* because the two are different things and pretending otherwise is what the
* export of a derived track used to do. {@link profileToTsv} writes `bin`
* first, which is meaningful only where there is a grid and every row sits on
* it; these rows sit where the data does.
*
* The empty rows {@link profileToTsv} keeps are not kept here, and that is the
* same decision rather than a different one. A profile has a value *per bin*, so
* a missing bin is a hole in a sequence a reader can see the shape of; a
* comparison of two tracks is defined only where both operands say something,
* so there is no row to leave blank — the intervals between are not empty
* readings, they are places the question was not asked.
*
* `contigs` orders the file, exactly as it orders the other two writers, so the
* three agree line for line and a reader can diff them.
*/
export function spansToTsv(
label: string,
unit: string,
spans: readonly TsvSpans[],
contigs: ReadonlyArray<{ name: string; length: number }>,
provenance: Provenance,
): Iterable<string> {
const lines = [
...provenanceLines(provenance),
`# output: ${cell(label)}${unit === '' ? '' : ` (${cell(unit)})`}`,
row(['chrom', 'start', 'end', 'value']),
]
const byContig = new Map<string, TsvSpans[]>()
for (const span of spans) {
const held = byContig.get(span.contig)
if (held === undefined) byContig.set(span.contig, [span])
else held.push(span)
}
for (const contig of contigs) {
const here = byContig.get(contig.name)
if (here === undefined) continue
const items: Array<[number, number, number]> = []
for (const span of here) {
for (let at = 0; at < span.starts.length; at += 1) {
const value = span.values[at]
if (!Number.isFinite(value)) continue
const start = Math.max(0, span.starts[at])
const end = Math.min(contig.length, span.ends[at])
if (end > start) items.push([start, end, value])
}
}
items.sort((one, two) => one[0] - two[0])
for (const [start, end, value] of items) {
lines.push(row([cell(contig.name), String(start), String(end), tsvNumber(value)]))
}
}
return pieces(lines)
}
/** One output, with its provenance above it, as the pieces of a `.tsv` (see {@link pieces}). */
export function* outputToTsv(output: TaskOutput, provenance: Provenance): Generator<string> {
yield* pieces(provenanceLines(provenance))
yield* outputPieces(output)
}
/**
* An output drawn **across several maps** — the drawer's all-maps tab (§18.7):
* a stacked table, an overlay of every map's curves — with every map's
* provenance above it.
*
* One block per map, in the order the rows name them, each opened by the name
* the map column uses and indented under it. A stacked table has no single
* provenance, and one printed for the first map would be a claim about the
* other seven that is not true — they are other files, and they may have been
* run at other times with other settings.
*/
export function* combinedToTsv(
output: TaskOutput,
maps: ReadonlyArray<{ label: string; provenance: Provenance }>,
): Generator<string> {
yield* pieces(combinedProvenanceLines(maps))
yield* outputPieces(output)
}
/**
* Every map's provenance, as the head of an export across several maps and as
* what *copy provenance* puts on the clipboard there: the build once, then one
* indented block per map, opened by its name.
*/
export function combinedProvenanceLines(
maps: ReadonlyArray<{ label: string; provenance: Provenance }>,
): string[] {
const head: string[] = []
const app = maps[0]?.provenance.app
if (app !== undefined) head.push(`# chromosomix ${cell(app)}`)
head.push(`# maps: ${tsvNumber(maps.length)}`)
for (const map of maps) {
head.push(`# map: ${cell(map.label)}`)
// Everything but the build, which is the file's and said once above.
for (const line of provenanceLines(map.provenance).slice(1)) head.push(`# ${line.slice(2)}`)
}
return head
}
function outputPieces(output: TaskOutput): Iterable<string> {
switch (output.kind) {
case 'profile':
return profileToTsv(output)
case 'curve':
return pieces(curveToTsv(output))
case 'matrix':
return pieces(matrixToTsv(output))
case 'table':
return pieces(tableToTsv(output))
case 'intervals':
return pieces(intervalsToTsv(output))
}
}
// ---------------------------------------------------------------------------
// The name it is saved under
// ---------------------------------------------------------------------------
/**
* `insulation_w10-chrIV-20260808-145233.tsv`.
*
* Three parts: what was run, which of its outputs this is, and when — so a
* directory of exports from an afternoon of parameter sweeps sorts by task and
* then by time, and no run overwrites another.
*
* Sanitised through `exportBaseName`, imported rather than reimplemented: the
* rule about which characters a filesystem objects to is one rule, and a second
* copy of it is the copy that forgets the `×` a genome × genome view puts in its
* title. The output segment is dropped where a task has one output that shares
* the base's name, since `insulation-insulation-…` says nothing twice.
*/
export function tsvName(base: string, output: TaskOutput, stamp: string): string {
const stem = exportBaseName(base)
const id = output.id === '' ? '' : exportBaseName(output.id)
const parts = id === '' || id === stem ? [stem] : [stem, id]
return `${[...parts, stamp].join('-')}.tsv`
}
|