All files / src/ui CellsTable.ts

80.5% Statements 128/159
75.67% Branches 28/37
69.23% Functions 9/13
80.5% Lines 128/159

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                                            2x 2x 2x 2x 2x     2x     2x                                                                                 2x 2x 12x 12x 12x   2x 12x 12x 12x   12x 12x 2x 2x 12x 12x 12x 12x 12x 12x 12x   12x 12x 12x 12x 12x 12x 12x 12x     12x 12x 4x 4x 4x 4x 3x 1x 1x         4x 4x     4x 4x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x   12x         12x 12x 12x 12x   12x 1x 1x 1x 1x             1x 1x 1x 12x   12x 12x 12x 12x 12x 12x           12x 12x 12x 12x 12x         12x 12x 12x 12x 12x 12x     2x       2x       2x     2x                       12x 12x 12x 12x 12x 12x 12x   12x 48x 48x 24x 24x 24x 24x       24x 24x 24x 24x 24x 24x 48x 48x 48x 48x 48x 48x 12x 12x 12x   48x 48x 48x 12x   12x                                              
/**
 * The cells table — the landing view of an `.scool` (spec §5.1, §3.8).
 *
 * The genomes table's sibling, and deliberately its near-twin: a sortable,
 * filterable list of everything in the file, tick boxes down the side, and one
 * button that turns the ticks into a picture. A reader who has used one has
 * used the other.
 *
 * What differs is what a tick *means*, and it is the whole of the difference.
 * Ticking genomes lays them out **beside** each other — each on the diagonal,
 * the blocks between them off it — because two MAGs are two places in one bin
 * space. Ticking cells **adds them up**: every cell of an scool indexes the
 * same bins, so a set of them is one map of the same genome with more contacts
 * in it. The button therefore says *sum*, and the hint says so in words, since
 * this is the one thing about the view a reader could otherwise get wrong.
 *
 * The selection is also **sticky**, which the genomes table's is not. It is not
 * only the subject of one picture; it is which cells the file is being read
 * through from here on, so it survives navigating into a chromosome and out
 * again (`app/Session.ts`). The count in the header is what says so.
 */
 
import { payloadColumnIsText, type CellsResult } from '../worker/protocol'
import { button, el, fmt } from './dom'
import { KEYS, keyedButton, withKey } from './commands'
import { SearchBox } from './SearchBox'
import { VirtualTable, type ColumnDef, type VirtualTableOptions } from './VirtualTable'
 
/** Columns every cooler writes, in the order they are shown. */
const CORE_ORDER = ['name', 'nnz', 'sum']
 
/** Above this many ticked cells, the button says the read is a big one. */
const MANY_CELLS = 64
 
/**
 * Numbers a run measured per cell, to be shown beside the ones the file records.
 *
 * The reason the per-cell QC task exists (`analysis/tasks/cellqc.ts`) is that
 * this table could only rank cells by `nnz` and `sum` — what the *writer* put in
 * the attributes, which is a statement about the sequencing. A measurement of
 * the contacts belongs in the same table, sortable and filterable by the same
 * machinery, because the gesture it is for is the one this table already has:
 * sort, tick a run of rows, sum them.
 *
 * Keyed by cell name rather than by row, so a result computed over the file
 * still lines up after a filter, a re-sort, or a re-open — and a cell the run
 * did not cover is simply blank.
 */
export interface MeasuredCells {
  columns: Array<{ key: string; label: string; hint?: string }>
  /** Cell name to that cell's measured values. */
  values: Map<string, Record<string, number>>
  /** What produced them, for the column tooltips. */
  from: string
}
 
export interface CellsTableOptions {
  data: CellsResult
  /** Per-cell numbers from an analysis run, appended as columns. */
  measured?: MeasuredCells
  /** Total cells in the file, which is `data.table.length` — passed for clarity. */
  persistence?: VirtualTableOptions['persistence']
  /**
   * Sum the ticked cells and show them as one map.
   *
   * The plural of "open this cell", and the same call with one name in it: a
   * single cell is a sum of one, and nothing downstream distinguishes them.
   */
  onShowCells(names: string[]): void
  /** Every cell in the file, summed — the pseudo-bulk. */
  onShowAll(): void
}
 
export class CellsTable {
  readonly el: HTMLElement
  private readonly table: VirtualTable
  private readonly names: string[]
  private readonly search: SearchBox
 
  constructor(options: CellsTableOptions) {
    const { data } = options
    this.names = data.table.data['name'] as string[]
    const total = data.table.length
 
    const columns = buildColumns(data, options.measured)
    const show = button('sum selected →', () => {
      const rows = this.table.selection
      if (rows.length > 0) options.onShowCells(rows.map((row) => this.names[row]))
    })
    show.disabled = true
    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({ length: total }, (_, row) => row),
      selectable: true,
      searchKeys: ['name'],
      searchPlaceholder: 'filter cell names…',
      emptyMessage: 'No cells match these filters.',
      ...(options.persistence === undefined ? {} : { persistence: options.persistence }),
      // A click opens that cell on its own, which is the per-cell view and the
      // most common thing anybody does here.
      onOpen: (row) => options.onShowCells([this.names[row]]),
      onSelect: (rows) => {
        show.disabled = rows.length === 0
        none.disabled = rows.length === 0
        show.textContent =
          rows.length === 1
            ? `show ${this.names[rows[0]]} →`
            : rows.length > 1
              ? `sum ${fmt.int(rows.length)} cells →`
              : 'sum selected →'
        // A set this large is a walk of every one of their pixel tables, and
        // saying so on the button is cheaper than saying it in a dialog after
        // the click. The gate itself lives where the read is decided.
        show.title =
          rows.length > MANY_CELLS
            ? `Sum ${fmt.int(rows.length)} cells. This reads every one of their pixel tables, ` +
              'so it is a longer wait than a single cell.'
            : 'Add the ticked cells together and draw them as one map'
      },
    })
    this.table.addToolbarItem(none)
    this.table.addToolbarItem(show)
    this.table.addToolbarItem(
      keyedButton(`sum all ${fmt.int(total)} →`, KEYS.whole, () => options.onShowAll(), {
        title: withKey(
          `Every cell in this file added together — the pseudo-bulk map. Reads all ${fmt.int(total)} pixel tables.`,
          KEYS.whole,
        ),
      }),
    )
 
    this.search = new SearchBox({
      // What it *takes*, which is what tells it from the box directly under it.
      // `cell name` over `filter cell names…` was two boxes for one thing, and
      // a reader typing half a barcode into the wrong one got a filtered list
      // where they expected a map.
      placeholder: 'cell name, or a prefix to sum',
      go: true,
      title:
        'Jump to a cell by name. The box above the rows filters the list; this one goes ' +
        'straight to a cell and opens it.',
      onSubmit: (text) => {
        const wanted = text.trim()
        if (wanted === '') return
        const exact = this.names.indexOf(wanted)
        if (exact >= 0) {
          options.onShowCells([this.names[exact]])
          return
        }
        // No exact match: treat it as a prefix, which is what a reader typing
        // half a barcode meant. Several matches sum, which is the view this
        // table is for — `Diploid_10` is a plate, not a typo.
        const prefixed = this.names.filter((name) => name.startsWith(wanted))
        if (prefixed.length > 0) options.onShowCells(prefixed)
      },
    })
 
    this.el = el(
      'section',
      { class: 'view view-cells' },
      el(
        'header',
        { class: 'view-head' },
        // No count in the heading. `Cells (3,882)` put the number a few lines
        // above the row count the toolbar already prints, and neither of the two
        // tables beside this one carries one — the three landing views are near
        // twins and should look like it. What the header was said to be for, the
        // *sticky* selection, it never said: the number in it was the total.
        el('h2', { text: 'Cells' }),
        el('p', {
          class: 'hint',
          text:
            'Click a cell to open its own contact map. Tick any number and they are ' +
            'added together — every cell here indexes the same bins, so a set of them is ' +
            'one map with their contacts summed, which is what makes a sparse single-cell ' +
            'matrix readable. The chosen cells stay chosen as you move into a chromosome ' +
            'and back out.',
        }),
        this.search.el,
      ),
      this.table.el,
    )
  }
 
  /** The `/` key, as in every other view: the name box, not the filter. */
  focusSearch(): void {
    this.search.focus()
  }
 
  destroy(): void {
    this.table.destroy()
  }
 
  clearSelection(): void {
    this.table.clearSelection()
  }
}
 
/**
 * Build the columns from whatever the cells' attributes turned out to hold.
 *
 * `name`, `nnz` and `sum` lead because every cooler writes the latter two and
 * they are what a single-cell library is ranked by — depth is the axis that
 * separates a usable cell from an empty one, and the file states it per cell
 * for nothing. Anything else the reader found *differing* across cells follows
 * in its own column; anything constant was dropped upstream, where it is a fact
 * about the file rather than about a cell (`MetaCooler.cellTable`).
 */
function buildColumns(data: CellsResult, measured?: MeasuredCells): 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)),
  ]
 
  const base: ColumnDef[] = ordered.map((name) => {
    const column = payload.data[name]
    if (payloadColumnIsText(column)) {
      return {
        key: name,
        label: name,
        kind: 'string',
        // Cell names are long — a barcode pair and a lane, routinely forty
        // characters — so the name column is given room the genomes table's
        // does not need, and the numbers stay narrow beside it.
        width: name === 'name' ? 'minmax(16rem, 2.4fr)' : 'minmax(7rem, 1fr)',
        value: (row) => (column[row] === '' ? undefined : column[row]),
      }
    }
    return {
      key: name,
      label: name === 'nnz' ? 'nnz' : name === 'sum' ? 'contacts' : name,
      kind: 'number',
      align: 'right',
      width: 'minmax(6rem, 0.7fr)',
      title:
        name === 'nnz'
          ? "Stored pixels in this cell's own matrix, from its attributes — no pixel is read."
          : name === 'sum'
            ? 'Total contacts in this cell, from its attributes. The depth a cell was sequenced to.'
            : undefined,
      value: (row) => column[row],
      format: (value) => (typeof value === 'number' ? fmt.num(value) : ''),
    }
  })
 
  if (measured === undefined) return base
 
  const names = data.table.data['name'] as string[]
  return [
    ...base,
    ...measured.columns.map((column): ColumnDef => ({
      key: `measured:${column.key}`,
      label: column.label,
      kind: 'number',
      align: 'right',
      width: 'minmax(6rem, 0.7fr)',
      title:
        column.hint === undefined
          ? `Measured by ${measured.from}.`
          : `${column.hint} Measured by ${measured.from}.`,
      value: (row) => measured.values.get(names[row])?.[column.key],
      // Three decimals, because every one of these is a fraction. `fmt.num` is
      // right for a depth and wrong for a share of one.
      format: (value) =>
        typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : '',
    })),
  ]
}