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 | 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 17x 17x 17x 2x 17x 17x 17x 17x 1x 1x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 9x 9x 9x 9x 4x 5x 3x 2x 1x 1x 9x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 2x 2x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 2x 1x 1x 2x 2x 2x 17x 17x 17x 17x 17x 17x 17x 17x 17x 85x 85x 85x 85x 85x 85x 51x 34x 85x 34x 34x 34x 34x 34x 34x 34x 34x 34x 34x 51x 51x 51x 51x 51x 51x 51x 51x 51x 51x 51x 387x 387x 387x 51x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 51x 51x 51x 204x 204x 204x 187x 187x 187x 204x 51x 51x | /**
* The genomes table — the landing view (spec §5.1).
*
* Opening a file lands here, not on a heatmap: it costs two small reads, it is
* the operation a metagenomics user actually performs, and a 4,000 × 4,000
* heatmap of scalars is less informative than a sortable MAG table carrying the
* quality metrics the binner produced.
*
* Two rules from the spec are structural here rather than optional:
*
* - **No column names are hard-coded.** `name`, `length`, `n_contigs` and
* `n_bins` are ordered first because the format guarantees them; everything
* else — completeness, contamination, taxonomy, genomad scores, whatever the
* creator joined in — is discovered from the payload and rendered generically.
* - **`__unbinned__` is absent, with no toggle to reveal it** (§7.4). The row is
* simply not among the displayable indices. Nothing is renumbered to achieve
* that: the omitted genome keeps its index, its `genome_id` and all of its
* bins, and the one number retained about it lives in the info panel.
*/
import { typedLocusToRegion } from '../genome/assembly'
import { payloadColumnIsText, type GenomesResult } from '../worker/protocol'
import { button, el, fmt } from './dom'
import { KEYS, keyedButton, withKey } from './commands'
import { SearchBox, locusPlaceholder } from './SearchBox'
import { VirtualTable, type ColumnDef, type VirtualTableOptions } from './VirtualTable'
/** Columns the format guarantees, in the order they are shown. */
const CORE_ORDER = ['name', 'length', 'n_contigs', 'n_bins']
/**
* The reader's word for each of the four columns the format guarantees, and
* what each of them is.
*
* Only those four, and that is the line §5.1 draws: no *discovered* column's
* name is hard-coded, because whatever the creator joined in is theirs to name
* and a viewer that renamed one would be hiding what the file says. These four
* are not discovered — {@link CORE_ORDER} already names them — and they were
* printed as the identifiers they are stored under, so the header row read
* `name length n_contigs n_bins` two inches from a File card calling the same
* things `contigs` and `bins`.
*
* The raw key stays the column's `key`, so sorts, filters and anything
* persisted are untouched. The tooltip is the other half of the fix and it is
* about the *neighbours*: a CheckM table joined into this file brings a
* `Length` of its own, so the reader gets two columns of identical numbers
* under two capitalisations and no way to tell which they are sorting by.
* Dropping the discovered one would be a viewer deciding a column the creator
* joined in is redundant, which §5.1 forbids and which would be wrong the first
* time the two disagreed; saying what this one is costs nothing and answers the
* question.
*/
const CORE_LABELS: Record<string, { label: string; title: string }> = {
name: { label: 'name', title: 'name — this genome, as the file’s genomes table names it' },
length: {
label: 'length',
title: 'length — the bases of this genome, from the file’s own genomes table',
},
n_contigs: {
label: 'contigs',
title: 'contigs — how many sequences the file assigns to this genome (n_contigs)',
},
n_bins: {
label: 'bins',
title: 'bins — how many rows of the matrix this genome covers (n_bins)',
},
}
export interface GenomesTableOptions {
data: GenomesResult
/** Restores the filters, search and sort a user left behind (`state/UiMemory`). */
persistence?: VirtualTableOptions['persistence']
onOpenGenome(name: string): void
/**
* Show every ticked genome as one map — their own on the diagonal, everything
* between them off it.
*
* The plural of {@link onOpenGenome}, and it subsumes what "compare two"
* used to do: a pair is a selection of two, plus the two diagonals that make
* the off-diagonal block readable.
*/
onOpenSelection(names: string[]): void
/**
* Open the whole file at once.
*
* The same place the root crumb's menu leads, offered here as well because
* the crumb is a chevron in the corner and this is the screen someone is on
* when they wonder whether the whole thing can be seen at once.
*/
onOpenWhole(): void
/**
* Go straight to a window, without opening the genome that contains it.
*
* The twin of the contigs table's box, and the same gap: every other door on
* this screen opens a *whole* something — a genome, a selection of them, the
* file — while a reader who knows the ten kilobases they want has to walk
* through two of those to reach a box that will take them. The reader is
* resolved by `parseMetacoolerRange`, which takes a genome name, a contig
* name, or either with coordinates on it, so one box answers all of them.
*/
onOpenRegion(region: string): void
}
export class GenomesTable {
readonly el: HTMLElement
private readonly table: VirtualTable
private readonly names: string[]
private readonly locus: SearchBox
constructor(options: GenomesTableOptions) {
const { data } = options
this.names = data.table.data['name'] as string[]
const columns = buildColumns(data)
const show = button('show selected →', () => {
const rows = this.table.selection
if (rows.length > 0) options.onOpenSelection(rows.map((row) => this.names[row]))
})
show.disabled = true
// "Select all" has moved into the filter row, as a box in the gutter above
// the ticks it sets — see the contigs table for why a toolbar button could
// not say which rows it meant.
const none = button('clear selection', () => this.table.clearSelection(), {
class: 'btn btn-quiet',
title: 'Untick everything, including rows the filters are hiding',
})
none.disabled = true
this.table = new VirtualTable({
columns,
rows: Array.from(data.display),
selectable: true,
searchKeys: ['name'],
// `filter`, not `search`: this box narrows the list under it, and `search`
// is what the command palette and a store's full-text box do (§26.4).
searchPlaceholder: 'filter genome names…',
emptyMessage: 'No genomes match these filters.',
...(options.persistence === undefined ? {} : { persistence: options.persistence }),
onOpen: (row) => options.onOpenGenome(this.names[row]),
onSelect: (rows) => {
show.disabled = rows.length === 0
none.disabled = rows.length === 0
// Two are named, because two is the comparison this table was built for
// and reading "MAG_7 × MAG_12 →" is worth more than a count. Past that
// the names would be longer than the button.
// One is named, because a name says what is about to open and a count
// does not: the Cells table beside this one has always said
// `show <name> →` for a single tick, and `show 1 selected →` was this
// table answering the same gesture with arithmetic.
show.textContent =
rows.length === 1
? `show ${this.names[rows[0]]} →`
: rows.length === 2
? `show ${this.names[rows[0]]} × ${this.names[rows[1]]} →`
: rows.length > 0
? `show ${fmt.int(rows.length)} selected →`
: 'show selected →'
},
})
this.table.addToolbarItem(none)
this.table.addToolbarItem(show)
this.table.addToolbarItem(
keyedButton('whole metagenome →', KEYS.whole, () => options.onOpenWhole(), {
title: withKey('Every genome at once, as one map', KEYS.whole),
}),
)
this.locus = new SearchBox({
// One builder for every locus box in the application (§26.6), and the
// example is this file's own first sequence rather than a grammar spelled
// out in nouns: four boxes asked for the same thing in four phrasings.
placeholder: locusPlaceholder({
first: this.names[0],
accepts: { sequence: true, genome: true },
}),
title:
'Open a window without opening the genome around it first — MAG_012, a contig of ' +
'one, or contig:1,200,001-1,210,000. Coordinates are 1-based and inclusive, as IGV ' +
'and samtools write them. The same box the / key opens.',
go: true,
// 1-based inclusive in, the machine form out (§26.1). This box used to
// hand the digits to `parseMetacoolerRange` untouched, which reads them
// 0-based and half-open — so the same coordinates named one window here
// and a different one in the browser, and the hint had to tell a reader
// which of the two bases they were in.
onSubmit: (text) => {
if (text !== '') options.onOpenRegion(typedLocusToRegion(text))
},
})
this.el = el(
'section',
{ class: 'view view-genomes' },
el(
'header',
{ class: 'view-head' },
el('h2', { text: 'Genomes' }),
// One sentence (§26.4). It was four lines of prose over a control two
// lines tall, and the two clauses it lost are said by the screen: the
// box says what it takes in its own placeholder, and what a selection of
// genomes draws is the picture the button opens.
el('p', {
class: 'hint',
text: 'Click a genome to open its matrix; tick several to show them as one map.',
}),
this.locus.el,
),
this.table.el,
)
}
/**
* The `/` key: the **locus** box, as in every other view of this application.
*
* The filter keeps its own box directly above the rows — visible, and one
* click away, which is where a reader narrowing a list is already looking.
* `/` is the key that opens the region bar on a map and the search field in
* the browser, and a table is not the place for it to mean something else.
*/
focusSearch(): void {
this.locus.focus()
}
/** Release the table's listeners and flush its pending filter state. */
destroy(): void {
this.table.destroy()
}
clearSelection(): void {
this.table.clearSelection()
}
}
/**
* Build the column list from whatever the file's `/genomes` table contains.
*
* The final column is the free density proxy of §4 —
* `bin1_offset[hi] − bin1_offset[lo]`, two lookups into an index already in
* memory. It is sortable because ranking MAGs by contact density is useful, and
* it is labelled a proxy in both the header and its tooltip because it is
* **not** a contact count and must never be read as one.
*/
function buildColumns(data: GenomesResult): ColumnDef[] {
const payload = data.table
const present = new Set(payload.columns)
const ordered = [
...CORE_ORDER.filter((name) => present.has(name)),
...payload.columns.filter((name) => !CORE_ORDER.includes(name)),
]
// Where the format's own columns end. Everything from here on was joined in
// by whoever made the file, and the first of them carries the rule that says
// so — the header's half of the record card's *joined in with the file*.
const first = ordered.find((name) => !CORE_ORDER.includes(name))
const columns: ColumnDef[] = ordered.map((name) => {
const column = payload.data[name]
const core = CORE_LABELS[name]
const label = core?.label ?? name
const joined = name === first ? { joined: true } : {}
const title =
core !== undefined
? { title: core.title }
: // No label of ours, by §5.1 — but the reader hovering a second
// `Length` deserves to be told which one they are sorting by.
{ title: `${name} — joined in with the file, not part of the format` }
if (payloadColumnIsText(column)) {
return {
key: name,
label,
...title,
...joined,
kind: 'string',
width: name === 'name' ? 'minmax(10rem, 1.4fr)' : 'minmax(7rem, 1fr)',
// An empty fixed-length string is padding, not a value: treating it as
// missing is what keeps "contamination < 5" from matching blank rows.
value: (row) => (column[row] === '' ? undefined : column[row]),
}
}
// One decimal count for the whole column, measured once.
//
// `null` where the column holds values too small for three places to say
// anything about them, and then each cell keeps `fmt.num`'s significant
// digits: a per-column count would print a set of 1e-5 scores as a column
// of `0.000`.
const digits = columnDigits(column)
return {
key: name,
label,
...title,
...joined,
kind: 'number',
align: 'right',
width: 'minmax(5.5rem, 0.7fr)',
value: (row) => column[row],
format: (value) =>
typeof value !== 'number'
? ''
: digits === null
? fmt.num(value)
: fmt.fixed(value, digits),
}
})
columns.push({
key: '__density__',
// The words §5.1 uses for it, and the words the File card beside this table
// uses for the same quantity: `nnz rows` is the worker's own name for a
// slice of `bin1_offset` and names nothing a biologist has heard of.
label: 'non-zero pixels (proxy)',
kind: 'number',
align: 'right',
width: 'minmax(7rem, 0.8fr)',
title:
'Number of non-zero pixels whose row bin falls inside this genome ' +
'(bin1_offset[hi] − bin1_offset[lo]). A free density proxy — NOT a contact count.',
value: (row) => data.density[row],
format: (value) => (typeof value === 'number' ? fmt.int(value) : ''),
})
return columns
}
/**
* How many decimal places a numeric column should print, or `null` for none.
*
* The **most** any value in it actually carries, capped at three, because that
* is the count at which no row loses a digit and none gains a zero it did not
* earn: a column of `0.99`, `1`, `0.98` prints two places throughout and a
* column of integers prints none. Measured over the whole column rather than
* sampled — a MAG table is thousands of rows, this runs once per open, and the
* one value with three places is exactly the one a sample would miss.
*
* `null` where anything in the column is below what three places can show, so
* a set of tiny scores keeps `fmt.num`'s significant digits instead of being
* flattened to `0.000`.
*/
function columnDigits(column: ArrayLike<number>): number | null {
let digits = 0
for (let row = 0; row < column.length; row++) {
const value = column[row]
if (value === undefined || !Number.isFinite(value)) continue
if (value !== 0 && Math.abs(value) < 1e-3) return null
// Asked of the *rounded* text rather than of the float: a bigWig's 0.98
// arrives as 0.9800000190734863, and its seventeen digits are the reader's
// business only insofar as three of them are. `toFixed` rather than
// `fmt.num`, because this counts characters and `fmt.num` is localised —
// on a machine whose numbers read `0,99` the point would not be found and
// every column would come out as integers.
const text = value.toFixed(3).replace(/0+$/, '')
const point = text.indexOf('.')
if (point >= 0) digits = Math.max(digits, text.length - point - 1)
}
return digits
}
|