All files / src/figures tableFigure.ts

94.91% Statements 112/118
73.58% Branches 39/53
85.71% Functions 6/7
94.91% Lines 112/118

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                                        2x 2x                                       2x 22x 22x 22x 22x 22x 21x 21x   22x 1x 1x 6x 6x                         2x 4x 4x 4x 4x 4x 4x 1x 2x 1x 1x 4x 4x 1x 4x 4x 4x 3x 4x 1x 1x 1x 1x 1x   4x 4x     2x 2x 2x       2x 2x                 2x 2x 2x 2x 2x 2x 2x   2x 2x 2x 2x 2x 2x 2x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x       5x 5x 2x 2x 2x   2x 2x 3x 3x 3x 3x 3x 8x 8x 8x 8x 3x 3x 3x 3x   2x 2x 2x 2x 2x 2x     2x 2x 2x 2x 2x 2x   2x 2x   2x   2x 2x 2x 2x 2x   2x 2x  
/**
 * A result table the reader can search, sort and narrow.
 *
 * The table the drawer has always drawn was a *record*: rows in the order the
 * task produced them, every column, no controls, checkable by eye and
 * exportable as TSV. That is the right thing for twelve rows and the wrong
 * thing for four thousand MAGs, where the question is almost never "what is in
 * row 1,207" and almost always "which of these is worst".
 *
 * So the rows are a view of the output rather than a printing of it, and the
 * view is a {@link FigureStyle} — the same object the charts are styled by,
 * remembered in the same place. Which means the filter a reader typed survives
 * a re-run, and the TSV beside the table can offer what they are looking at.
 *
 * Still not a {@link VirtualTable}: virtualisation exists here for 154,000
 * contigs, and a task's table is hundreds to a few thousand rows, which is one
 * layout pass. The typography is the virtual table's, because a number in a
 * result should be set like a number in a genomes table.
 */
 
import { baseLabel } from '../genome/assembly'
import { el, fmt } from '../ui/dom'
import type { TableColumn } from '../analysis/types'
import type { TableFigureSpec } from './spec'
import type { FigureStyle } from './style'
 
export interface TableView {
  el: HTMLElement
  destroy(): void
}
 
/**
 * What a cell says. `NaN` is *no value here* — an em dash, never a zero.
 *
 * A coordinate is said as the rest of the screen says one
 * (`TableColumn.coordinate`): a start, held 0-based, as the first base it
 * names, 1-based; a start and an end alike grouped as en-US whatever the
 * locale. Here and only here — the drawer's table and this one both print
 * through this, and the filter matches what is printed, so typing the base a
 * row shows finds it.
 */
export function cellText(column: TableColumn, raw: string | number | undefined): string {
  if (raw === undefined) return '—'
  if (column.kind !== 'number') return String(raw)
  const value = typeof raw === 'number' ? raw : Number(raw)
  if (!Number.isFinite(value)) return '—'
  if (column.coordinate === 'start') return baseLabel(value)
  if (column.coordinate === 'end') return fmt.pos(value)
  if (column.digits === undefined) return fmt.int(value)
  // Too small for its digits, in a column that says it may be (a P-value).
  if (column.scientific === true && value !== 0 && Math.abs(value) < 10 ** -column.digits) {
    return value.toExponential(2)
  }
  return value.toFixed(column.digits)
}
 
/**
 * The rows a style leaves, in the order it asks for.
 *
 * The filter is a plain substring over every cell's *text*, which is the search
 * a reader means when they type `bin.4` into a box over a table. Matching the
 * underlying numbers instead would make `98` fail to find a completeness of
 * `98.2` on a column rounded to one place, which is the number they can see.
 *
 * Sorting puts what is missing last whichever way the column runs, because a
 * row with no value is not a row with the smallest one.
 */
export function viewRows(
  spec: TableFigureSpec,
  style: FigureStyle,
): Array<Record<string, string | number>> {
  const needle = style.filter.trim().toLowerCase()
  let rows = spec.rows
  if (needle !== '') {
    rows = rows.filter((row) =>
      spec.columns.some((column) => cellText(column, row[column.key]).toLowerCase().includes(needle)),
    )
  }
  const key = style.sortKey
  if (key === null) return rows
  const column = spec.columns.find((entry) => entry.key === key)
  if (column === undefined) return rows
  const sign = style.sortDown ? -1 : 1
  const missing = (value: string | number | undefined): boolean =>
    value === undefined || (column.kind === 'number' && !Number.isFinite(Number(value)))
  return [...rows].sort((a, b) => {
    const left = a[key]
    const right = b[key]
    if (missing(left) !== missing(right)) return missing(left) ? 1 : -1
    if (missing(left)) return 0
    if (column.kind === 'number') return sign * (Number(left) - Number(right))
    return sign * String(left).localeCompare(String(right))
  })
}
 
/** The columns a style leaves, in the table's own order. */
export function viewColumns(spec: TableFigureSpec, style: FigureStyle): TableColumn[] {
  const shown = style.columns
  if (shown === null) return spec.columns
  const kept = spec.columns.filter((column) => shown.includes(column.key))
  // A style that hid every column is a style that hides the table; the columns
  // come back rather than leaving a reader with an empty box and no way out.
  return kept.length === 0 ? spec.columns : kept
}
 
/**
 * The table, as the style leaves it.
 *
 * A column head is a button that sorts by it, which is what a head that looks
 * like a head promises. Pressing the column that is already the sort turns it
 * round, which is the gesture every table in this app already has.
 */
export function tableView(
  spec: TableFigureSpec,
  style: FigureStyle,
  onSort: (key: string, down: boolean) => void,
): TableView {
  const columns = viewColumns(spec, style)
  const rows = viewRows(spec, style)
 
  const head = el(
    'thead',
    { class: 'thead' },
    el(
      'tr',
      {},
      ...columns.map((column) => {
        const sorted = style.sortKey === column.key
        const cell = el('th', {
          class: `td figure-th${sorted ? ' is-sorted' : ''}${column.kind === 'number' ? ' num' : ''}`,
          text: `${column.label}${sorted ? (style.sortDown ? ' ↓' : ' ↑') : ''}`,
          title: column.hint ?? `Sort by ${column.label}`,
          attrs: { role: 'button', tabindex: '0' },
        })
        const flip = (): void => onSort(column.key, sorted ? !style.sortDown : false)
        cell.addEventListener('click', flip)
        cell.addEventListener('keydown', (event) => {
          if (event.key !== 'Enter' && event.key !== ' ') return
          event.preventDefault()
          flip()
        })
        return cell
      }),
    ),
  )
 
  const body = el('tbody', {})
  for (const row of rows) {
    body.appendChild(
      el(
        'tr',
        { style: { borderTop: '1px solid var(--border)' } },
        ...columns.map((column) =>
          el('td', {
            class: column.kind === 'number' ? 'td num' : 'td',
            text: cellText(column, row[column.key]),
          }),
        ),
      ),
    )
  }
 
  const table = el(
    'table',
    {
      style: {
        borderCollapse: 'collapse',
        width: '100%',
        // Digits of the same width, so a column of numbers can be compared by
        // its shape.
        fontVariantNumeric: 'tabular-nums',
      },
    },
    head,
    body,
  )
 
  const empty =
    spec.rows.length === 0
      ? 'This run produced no rows.'
      : `No row matches “${style.filter}”. ${spec.rows.length} were produced.`
 
  const host = el(
    'div',
    { class: 'analysis-figure-scroll' },
    rows.length === 0 ? el('div', { class: 'tempty', text: empty }) : table,
  )
 
  return { el: host, destroy(): void {} }
}