All files / src/ui RecordCard.ts

100% Statements 66/66
91.89% Branches 34/37
100% Functions 4/4
100% Lines 66/66

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                                                2x 2x 2x                                                                                       2x                                 2x 2x 2x 2x 2x               2x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x   14x 14x 14x 14x 51x 40x 40x     51x 8x 8x 8x 51x 8x 8x 51x 32x 32x 32x 51x 14x 14x 14x             32x 32x 32x 32x     2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 6x 1x 5x 5x 5x 5x 2x 2x 2x 2x  
/**
 * The record card: the `/genomes` or `/contigs` row that the heatmap on screen
 * is a picture *of*.
 *
 * A genome or contig view answers "what does this map look like"; the table it
 * came from answers "what is this thing" — completeness, contamination,
 * taxonomy, length, bin count, and whatever else the binner joined in. Both
 * questions are asked at the same moment and, until now, only one of them could
 * be answered without navigating back to the table and losing the view.
 *
 * Two rules are inherited from the tables rather than invented here, so a field
 * means the same thing in both places:
 *
 * - **No column names are hard-coded.** The guaranteed columns lead because the
 *   format guarantees them; every other column is discovered from the payload
 *   and rendered generically. A file with `Present Markers` and `GC-content`
 *   shows them, and this module never learns their names.
 * - **An empty fixed-length string is padding, not a value** (§5.1). It reads as
 *   an em dash, the same way the tables treat it as missing rather than as `''`.
 *
 * The DOM half is one function at the bottom; everything above it is pure, which
 * is what lets the field selection be tested in a Node environment.
 */
 
import { payloadColumnIsText, type TablePayload } from '../worker/protocol'
import { el, fmt } from './dom'
import { cardTitle, type IconName } from './icons'
 
/** One row of the card's key/value grid. */
export interface RecordField {
  key: string
  value: string
  /** Hover text for the key, where the column needs a caveat. */
  title?: string
  /**
   * A quiet line across the grid instead of a row, naming what follows it.
   *
   * One use: the break between the columns the **format** guarantees and the
   * ones the file's **creator** joined in. On a metagenome those two sets
   * overlap — `length 7,310,635 bp` and then `Length 7,310,635`, `n_contigs
   * 2,153` and then `Contigs 2,153` — and a card that listed them as eighteen
   * equal rows said every number twice with nothing to say why. Dropping the
   * creator's copy is not the answer (§5.1: a column somebody joined in is a
   * column they meant, and the first time the two disagree the difference is
   * the finding); saying which is which is.
   */
  divider?: boolean
}
 
export interface RecordSpec {
  /** `Genome`, `Contig (rows)` — what this record is, and on which axis. */
  heading: string
  /**
   * The mark before the heading, as every card in the three panels carries one.
   *
   * Named by the caller rather than derived from the heading, because the
   * heading is a *phrase* — "Chromosome (columns)" — and matching prose against
   * a list of nouns to pick a picture is the kind of cleverness that quietly
   * stops working the day a file calls its sequences something else.
   */
  icon: IconName
  name: string
  fields: RecordField[]
}
 
/**
 * Columns the format guarantees, first and in this order. The union of the two
 * tables' core columns: `/contigs` has no `n_contigs`, `/genomes` no
 * `genome_id`, and a name absent from a payload is simply skipped.
 */
const CORE_ORDER = ['name', 'length', 'n_contigs', 'n_bins', 'genome_id']
 
export interface RecordFieldOptions {
  /** Columns to leave out — `genome_id`, once its name has been resolved. */
  skip?: string[]
  /**
   * Placed before the file's own columns, for a field that is context rather
   * than data: which genome a contig belongs to is the first thing to know
   * about it, and a metagenomic contigs table is wide enough to push it out of
   * sight if it goes at the end.
   */
  lead?: RecordField[]
  /** Appended after the file's own columns. */
  extra?: RecordField[]
}
 
/** The row index of `name` in a payload's `name` column, or `-1`. */
export function findRow(payload: TablePayload, name: string): number {
  const names = payload.data['name']
  if (names === undefined || !payloadColumnIsText(names)) return -1
  return names.indexOf(name)
}
 
/**
 * Every column of one row, in display order, formatted as text.
 *
 * `name` is never a field: it is the card's title, and repeating it inside the
 * grid would push the informative columns down by a line.
 */
export function recordFields(
  payload: TablePayload,
  row: number,
  options: RecordFieldOptions = {},
): RecordField[] {
  if (row < 0 || row >= payload.length) return []
  const skip = new Set(['name', ...(options.skip ?? [])])
  const ordered = [
    ...CORE_ORDER.filter((name) => payload.columns.includes(name)),
    ...payload.columns.filter((name) => !CORE_ORDER.includes(name)),
  ]
 
  const core = new Set(CORE_ORDER)
  const fields: RecordField[] = [...(options.lead ?? [])]
  let broke = false
  for (const key of ordered) {
    if (skip.has(key)) continue
    const column = payload.data[key]
    if (column === undefined) continue
    // The one break: everything after this is a column somebody added to the
    // file rather than one the format promises. See {@link RecordField.divider}.
    if (!broke && !core.has(key) && fields.length > (options.lead ?? []).length) {
      fields.push({ key: 'joined in with the file', value: '', divider: true })
      broke = true
    }
    if (payloadColumnIsText(column)) {
      const text = column[row] ?? ''
      fields.push({ key, value: text === '' ? '—' : text })
    } else {
      const value = column[row]
      fields.push({ key, value: formatNumber(key, value) })
    }
  }
  fields.push(...(options.extra ?? []))
  return fields
}
 
/**
 * A length is base pairs and says so; everything else keeps the tables'
 * formatting, which is thousands separators for an integer and three places for
 * a float.
 */
function formatNumber(key: string, value: number | undefined): string {
  if (value === undefined || !Number.isFinite(value)) return '—'
  return key === 'length' ? `${fmt.int(value)} bp` : fmt.num(value)
}
 
/** Render one record as a sidebar card. */
export function recordCard(spec: RecordSpec): HTMLElement {
  return el(
    'div',
    { class: 'card record-card' },
    cardTitle(spec.heading, spec.icon),
    el('p', { class: 'record-name', text: spec.name, title: spec.name }),
    el(
      'div',
      { class: 'kv kv-record' },
      ...spec.fields.flatMap((field) =>
        field.divider === true
          ? [el('div', { class: 'kv-divider', text: field.key })]
          : [
              el('div', { class: 'k', text: field.key, title: field.title ?? field.key }),
              el('div', { class: 'v', text: field.value, title: field.value }),
            ],
      ),
    ),
  )
}