All files / src/ui/stores GalleryView.ts

96.03% Statements 121/126
86.66% Branches 39/45
90% Functions 9/10
96.03% Lines 121/126

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                                                1x 1x 1x   1x               1x 1x             1x 9x 9x   1x 13x 13x 13x           13x 13x 13x 13x 25x 25x 25x 1x 24x 25x 25x     13x 13x 13x 12x 12x 12x 12x 12x 12x 12x 12x 12x 13x   1x   6x               1x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x 89x         89x 2x 87x 87x 87x 87x 87x 89x 89x 89x 2x 87x 87x 87x 87x 87x 87x 89x 89x   1x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1506x 1x     13x 13x 13x 89x 89x 89x 64x 89x 13x 13x     178x 178x 178x 178x  
/**
 * The lab's own store: a gallery of composed figures, and the files under them.
 *
 * What a first-time reader should meet is the application doing its job — a
 * window worth looking at, tracks on both edges of the map, two strands
 * sharing a lane — and all of that is what a saved workspace writes down
 * (`ui/examples.ts`). So the front of this store is *cards*, one per published
 * session: what it shows, on which assembly, where the data came from, and one
 * button. The bare files are underneath, for the reader who wants the `.cool`
 * alone, drawn as rows with the same two verbs every store row has.
 *
 * Each card says enough to choose and no more: a title, one line, two chips.
 * The paragraph that used to be the whole row is behind a fold, because a
 * gallery of four paragraphs is a page to read rather than a shelf to pick
 * from.
 *
 * The cards stand under the headings the store files them under — a figure's
 * `section` field, *Demo* and *Papers* in the lab's — in the order each first
 * appears. Two kinds of card are on that shelf and a reader is looking for one
 * of them: a demonstration of what the viewer does, or the figures of a paper
 * they came to see. A store whose figures name no section is one run of cards,
 * as it always was.
 */
 
import { button, el } from '../dom'
import { icon, iconButton } from '../icons'
import { valueOf } from '../../state/facets'
import type { StoreEntry, StoreFile } from '../../state/store'
import { rowActions, rowDrag, type StoreHost } from './host'
import type { StoreFront } from './registry'
 
export interface GalleryViewProps {
  front: StoreFront
  host: StoreHost
}
 
export class GalleryView {
  readonly el: HTMLElement
 
  /**
   * The catalogue, which for this front is inline and therefore never pending:
   * it ships inside the bundle, so there is nothing to wait for and no
   * `onLoaded` to raise.
   */
  fileNow(): StoreFile | null {
    return this.props.front.rows?.inline ?? null
  }
 
  constructor(private readonly props: GalleryViewProps) {
    const file = props.front.rows?.inline ?? null
    this.el = el('div', { class: 'gallery' })
    if (file === null) {
      this.el.appendChild(
        el('p', { class: 'hint', text: 'This store carries nothing inline, so there is nothing to draw.' }),
      )
      return
    }
    const figures = file.entries.filter((entry) => entry.kind === 'workspace')
    const files = file.entries.filter((entry) => entry.kind !== 'workspace')
    const byUri = new Map(files.map((entry) => [entry.uri, entry] as const))
    for (const { name, rows } of sectionsOf(figures)) {
      const cards = el('div', { class: 'gallery-cards' }, ...rows.map((entry) => this.card(entry, byUri)))
      this.el.appendChild(
        name === ''
          ? cards
          : el('section', { class: 'gallery-section' }, el('h3', { class: 'gallery-heading', text: name }), cards),
      )
    }
    // A file no figure names still has a row, under the cards rather than
    // nowhere: the two maps published without a session of their own.
    const named = new Set(figures.flatMap((entry) => filesOf(entry)))
    const loose = files.filter((entry) => !named.has(entry.uri))
    if (loose.length > 0) {
      this.el.appendChild(
        el(
          'details',
          { class: 'gallery-card-more gallery-loose' },
          el('summary', { text: `${loose.length} more ${loose.length === 1 ? 'file' : 'files'}, in no figure` }),
          el('div', { class: 'gallery-card-files' }, ...loose.map((entry) => this.fileRow(entry))),
        ),
      )
    }
  }
 
  destroy(): void {
    /* nothing subscribed */
  }
 
  /**
   * One figure: its name and the one verb on a line, the chips under them, the
   * line that says what is on screen, and its files behind a fold — the same
   * shape IGV's genome cards have (`GenomesView.ts`), so the two galleries
   * read as one kind of thing.
   */
  private card(entry: StoreEntry, byUri: ReadonlyMap<string, StoreEntry>): HTMLElement {
    const assembly = valueOf(entry, 'assembly')
    const provenance = valueOf(entry, 'provenance')
    const detail = valueOf(entry, 'detail')
    const files = filesOf(entry)
      .map((uri) => byUri.get(uri))
      .filter((row): row is StoreEntry => row !== undefined)
    return el(
      'article',
      { class: 'gallery-card' },
      el(
        'div',
        { class: 'gallery-card-head' },
        el('h4', { class: 'gallery-card-title', text: entry.label }),
        iconButton(
          'new-session',
          `Open ${entry.label} in a new tab — the figure with every track it names.`,
          () => this.props.host.open(entry),
          { class: 'btn btn-icon row-verb row-verb-new gallery-card-open', size: 16 },
        ),
      ),
      el(
        'div',
        { class: 'gallery-card-chips' },
        assembly === '' ? null : el('span', { class: 'portal-chip', text: assembly }),
        // The chip clips at 22rem, so an accession and a DOI together end in an
        // ellipsis — and a reader who came to cite the dataset had nowhere to
        // read the rest: the title said what the chip was rather than what it
        // held. It now carries the whole string.
        provenance === ''
          ? null
          : el('span', {
              class: 'portal-chip portal-chip-dim',
              text: provenance,
              title: `Where the data came from: ${provenance}`,
            }),
      ),
      entry.note === undefined ? null : el('p', { class: 'gallery-card-line', text: entry.note, title: detail }),
      files.length === 0
        ? null
        : el(
            'details',
            { class: 'gallery-card-more' },
            el('summary', { text: `${files.length} ${files.length === 1 ? 'file' : 'files'}` }),
            el('div', { class: 'gallery-card-files' }, ...files.map((row) => this.fileRow(row))),
          ),
    )
  }
 
  private fileRow(entry: StoreEntry): HTMLElement {
    const format = valueOf(entry, 'format')
    return rowDrag(
      el(
        'div',
        { class: 'portal-row' },
        el(
          'span',
          { class: `portal-kind portal-kind-${entry.kind}` },
          icon(entry.kind === 'map' ? 'grid-2x2' : 'dna', { size: 12 }),
        ),
        el(
          'div',
          { class: 'portal-row-text' },
          button(entry.label, () => this.props.host.open(entry), { class: 'btn-link portal-name', title: entry.note ?? '' }),
          format === '' ? null : el('span', { class: 'portal-chip portal-chip-dim', text: format }),
        ),
        el('div', { class: 'portal-row-actions' }, ...rowActions(entry, this.props.host)),
      ),
      entry,
      this.props.host,
    )
  }
}
 
/** The figures by their `section` field, each section where its first figure stands. */
function sectionsOf(figures: readonly StoreEntry[]): Array<{ name: string; rows: StoreEntry[] }> {
  const out: Array<{ name: string; rows: StoreEntry[] }> = []
  for (const entry of figures) {
    const name = valueOf(entry, 'section')
    const held = out.find((section) => section.name === name)
    if (held === undefined) out.push({ name, rows: [entry] })
    else held.rows.push(entry)
  }
  return out
}
 
/** The URIs of the rows a figure is made of, from its `files` field. */
function filesOf(entry: StoreEntry): string[] {
  const held = valueOf(entry, 'files')
  return held === '' ? [] : held.split('|')
}