All files / src/ui StructureCard.ts

87.59% Statements 233/266
89.41% Branches 76/85
95.65% Functions 22/23
87.59% Lines 233/266

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 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436                                                        2x 2x 2x       2x 2x 2x 2x 2x                                                                       2x 2x 2x 2x 2x   2x 43x 43x 43x 43x 43x 43x 43x 43x 43x 43x       43x 43x 43x 43x   2x 15x 15x                           2x 43x       43x 43x         43x 43x 1x 1x 1x 1x 1x 43x 43x     2x 114x 114x   2x 114x       114x 114x 114x   2x 114x 114x   114x                           2x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 1x 1x   1x 1x     1x 1x 87x 87x 87x   2x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 29x 29x 29x 87x 87x 87x                                               2x 87x 87x 87x 87x 1x 1x 1x 1x 1x 1x 1x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 87x 86x   87x 87x 87x 87x 87x   2x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x   2x 87x 87x 87x 87x 87x 87x 87x 87x 87x   2x 87x 87x 87x 87x 87x 87x 87x 1x 1x       1x 87x 87x                     87x 87x 87x   2x           87x 87x 87x 87x 87x 87x 4x 2x 4x 87x 87x 87x 87x 87x 87x 2x     174x 174x 174x 174x     87x 87x 87x 87x     87x 87x 78x 47x 1x 1x   1x 87x   87x                                     43x 43x       43x 43x 43x 43x 43x 43x             43x 43x   43x 43x 43x 43x         43x 43x 43x  
/**
 * The **Structures** card: every 3D model the session holds, as rows (§25).
 *
 * A structure was a row of the Tracks card, among the bigWigs and the BEDs, for
 * as long as there was only ever one — a file the reader dropped, drawn by the
 * one cell of the deck. Now a model can also be *made* (the reconstruction task,
 * §25.6), and a list that mixes a loaded model, a computed one and eleven
 * tracks is a list nobody can find the model in. So models have a card: the
 * rows a reader loaded and the rows a task produced, one list, each with the
 * three things a model row is for and a track row is not.
 *
 * **The eye is the model's cell** — each model is drawn in a cell of its own
 * (§25.3), and the eye opens that cell and closes it, where a track's eye hides
 * a lane; the row's grip, dragged onto a cell, draws the model there instead.
 * The cell has no picker: which model a cell draws is this card's to say.
 * **bed3d** — the model out, as the file it would be loaded from, which is how
 * a computed one leaves the tab — and **PDB**, the same model for PyMOL and the
 * other molecular viewers, which this app also reads back. **The provenance** —
 * what made it and what it was made from, for a computed row, in two lines
 * under it, because a model whose framing is invisible is a picture nobody can
 * defend.
 *
 * Everything else on a row is the Tracks card's own head — the colour, the name,
 * the close — calling the same store with the same arguments, and a load zone
 * that takes the three formats and nothing else, so the sentence on it can say
 * what they are.
 */
 
import { taskById } from '../analysis/registry'
import { g3dSummary, writeBed3d } from '../annotations/g3d'
import { pdbRefusal, writePdb } from '../annotations/pdb'
import type { AnnotationLoader } from '../annotations/loader'
import type { AnnotationStore } from '../annotations/store'
import type { AnnotationEntry } from '../annotations/types'
import { chooseFiles, filesFrom } from './chooseFiles'
import { STRUCTURE_DRAG_TYPE, button, dragHasFiles, el } from './dom'
import { tabAmong } from './tabWalk'
import { downloadBlob, exportBaseName, fileStamp } from './export'
import { cardTitle, icon, iconButton } from './icons'
 
export interface StructureCardOptions {
  store: AnnotationStore
  loader: AnnotationLoader
  /**
   * Put this model in the deck's structure cell, opening the cell where there
   * is none. Absent where no deck is possible — a map-only build, a file with
   * no picture to sit beside — and the button is left off rather than wired to
   * nothing.
   */
  onShow?(entry: AnnotationEntry): void
  /**
   * Take **this row's** cell out of the deck: the eye's other half.
   *
   * A structure row has no lane and no track to hide, so `visible` meant
   * nothing on one — the eye toggled a flag no picture read. What a reader
   * means by the eye on a model is *stop showing it*, which is the cell. Per
   * row, because each model has a cell of its own (§25.3): closing one leaves
   * the others where they are.
   */
  onHide?(entry: AnnotationEntry): void
  /** Whether a cell is drawing this row, so its eye can say so. */
  shown?(id: string): boolean
  /** A detached computed row's way back: the task, with its recipe, in the drawer. */
  onRecompute?(entry: AnnotationEntry): void
  /**
   * When a computed row's run was made — the date its bed3d's `#` line carries,
   * as the drawer's button and `chromosomix` write it (§25.7). The analysis
   * workspace holds the run and the row does not, so the card asks. Absent, or
   * `undefined` for a row, and the line names no date: a model loaded from a
   * file was not made here.
   */
  createdAt?(entry: AnnotationEntry): string | undefined
}
 
export class StructureCard {
  readonly el: HTMLElement
  private readonly list = el('div', { class: 'ann-list' })
  private readonly unsubscribe: () => void
  private pending = false
 
  constructor(private readonly options: StructureCardOptions) {
    const zone = loadZone(options.loader)
    this.el = el(
      'section',
      { class: 'card annotation-card structure-card' },
      cardTitle('Structures', 'box'),
      ...zone,
      this.list,
    )
    this.cardDrops(this.el, zone[0])
    this.list.addEventListener('focusout', () => {
      setTimeout(() => {
        if (this.pending) this.refresh()
      }, 0)
    })
    this.unsubscribe = this.options.store.subscribe(() => this.refresh())
    this.refresh()
  }
 
  destroy(): void {
    this.unsubscribe()
  }
 
  /**
   * A file dropped anywhere on this card, as the Tracks card takes one.
   *
   * The zone said "drop one anywhere on this card" and nothing listened, so a
   * `.g3d` had to be aimed at the *Tracks* card to be loaded at all — the card
   * whose list it then does not join, since the loader files it as a structure
   * either way. The sentence was right and the card was missing the handler.
   *
   * A file of any other kind dropped here still goes where its bytes say
   * (§8.3): the loader decides by content, so the row lands where it should
   * and the aim is only about which card a reader thinks to drop it on.
   */
  private cardDrops(host: HTMLElement, zone: HTMLElement): void {
    host.addEventListener('dragover', (event) => {
      if (!dragHasFiles(event)) return
      event.preventDefault()
      zone.classList.add('over')
    })
    host.addEventListener('dragleave', (event) => {
      // Only when the pointer has left the card, not when it crosses onto a row
      // inside it: `dragleave` fires at every child boundary.
      if (event.relatedTarget instanceof Node && host.contains(event.relatedTarget)) return
      zone.classList.remove('over')
    })
    host.addEventListener('drop', (event) => {
      event.preventDefault()
      event.stopPropagation()
      zone.classList.remove('over')
      const files = filesFrom(event.dataTransfer)
      if (files.length > 0) this.options.loader.addFiles(files)
    })
  }
 
  /** Every structure row, loaded or computed — the store's order. */
  rows(): AnnotationEntry[] {
    return this.options.store.entries.filter((entry) => entry.kind === 'structure')
  }
 
  refresh(): void {
    if (this.holdsFocus()) {
      this.pending = true
      return
    }
    this.pending = false
    this.list.replaceChildren(...this.rows().map((entry) => this.row(entry)))
  }
 
  private holdsFocus(): boolean {
    const active = document.activeElement
    if (!(active instanceof HTMLElement) || !this.list.contains(active)) return false
    return active instanceof HTMLInputElement || active instanceof HTMLSelectElement
  }
 
  /**
   * The handle that carries a model into the deck.
   *
   * The Tracks card has had one of these since there were two tracks to put in
   * an order, and a reader with three models loaded had no way to say *that
   * one, there* but the row's own button — which always means the one structure
   * cell, wherever it happens to be. Dragging says where as well as which, and
   * an empty cell is a place a reader has already made room in.
   *
   * Reordering is not what this one does: the Structures card has no order to
   * change. It carries the id, under a type of its own, to whatever catches it.
   */
  private grip(entry: AnnotationEntry): HTMLElement {
    const node = el(
      'span',
      {
        class: 'ann-grip',
        title: 'Drag into an empty cell of the deck to draw this model there',
        attrs: { draggable: 'true', 'data-grip': entry.id },
      },
      icon('grip-vertical', { size: 12 }),
    )
    node.addEventListener('dragstart', (event) => {
      if (event.dataTransfer === null) return
      event.dataTransfer.effectAllowed = 'copy'
      // Some browsers cancel a drag that carries nothing at all.
      event.dataTransfer.setData('text/plain', entry.label)
      event.dataTransfer.setData(STRUCTURE_DRAG_TYPE, entry.id)
      // The row, not the handle: a twelve-pixel ghost says nothing about what
      // is being moved.
      const row = node.closest('.ann-row')
      if (row instanceof HTMLElement) event.dataTransfer.setDragImage(row, 12, 12)
    })
    return node
  }
 
  private row(entry: AnnotationEntry): HTMLElement {
    const shown = this.options.shown?.(entry.id) === true
    const row = el('div', {
      class: `ann-row ann-${entry.status}${shown ? ' is-shown' : ''}`,
      attrs: { 'data-id': entry.id },
    })
    row.appendChild(
      el(
        'div',
        { class: 'ann-head' },
        this.grip(entry),
        this.swatch(entry),
        this.name(entry),
        this.visibility(entry),
        iconButton('x', 'Remove this structure', () => this.options.store.remove(entry.id), {
          class: 'btn-close',
          size: 14,
        }),
      ),
    )
    const made = madeBy(entry)
    if (made !== null) row.appendChild(el('p', { class: 'ann-note', text: made }))
    const note = noteOf(entry)
    if (note !== null) row.appendChild(el('p', { class: `ann-note${entry.status === 'error' ? ' ann-error' : ''}`, text: note }))
    const framing = entry.source.kind === 'computed' ? entry.source.framing : ''
    if (framing !== '') row.appendChild(el('p', { class: 'ann-origin', text: framing, title: framing }))
    if (entry.source.kind === 'local' || entry.source.kind === 'remote') {
      const origin = entry.source.kind === 'local' ? entry.source.name : entry.source.url
      row.appendChild(el('p', { class: 'ann-origin', text: origin, title: origin }))
    }
    row.appendChild(this.actions(entry))
    return row
  }
 
  /**
   * What the row does that its head does not: run its recipe again, and write
   * the model out.
   *
   * **No *show in the deck* button.** There was one here, beside the eye that
   * does the same thing — two controls on one row for one act, which a reader
   * tries both of — and while the cell was drawing the row it relabelled itself
   * *shown in the deck* and went on calling `onShow`, so the obvious press
   * (pressing the lit one to un-show) did nothing. A button says what pressing
   * it does; where a thing is drawn is said by the eye and the row's accent
   * (§26.3, §26.7), which is how every other card in this column says it.
   *
   * `export` before the format, so the button's name is the file it writes and
   * the word in front is what pressing it is for — the same pair the Metadata
   * annotations card draws (§26.6). `bed3d` alone was a format noun with no verb
   * anywhere near it.
   *
   * **PDB** beside it is the same model for PyMOL (§25.7). A model the format's
   * columns cannot number — past 99,999 beads, or a chain past 9,999 — has the
   * button disabled with that reason as its tooltip, rather than a file whose
   * serials run into the next column.
   */
  private actions(entry: AnnotationEntry): HTMLElement {
    const payload = entry.payload
    const ready = entry.status === 'ready' && payload?.kind === 'features'
    const nodes: HTMLElement[] = []
    if (entry.status === 'detached' && entry.source.kind === 'computed' && this.options.onRecompute !== undefined) {
      nodes.push(
        button('recompute', () => this.options.onRecompute?.(entry), {
          class: 'btn',
          title: 'The model’s places were not carried across the reload; the recipe was. Run it again.',
        }),
      )
    }
    const refusal = ready && payload?.kind === 'features' ? pdbRefusal(payload.data) : null
    nodes.push(
      el('span', { class: 'field-label', text: 'export' }),
      disabledUnless(
        ready,
        button('bed3d', () => this.save(entry, 'bed3d'), {
          class: 'btn structure-bed3d',
          title: 'The model as a bed3d — chrom, start, end, x, y, z — which this app and g3dtools read back',
        }),
      ),
      disabledUnless(
        ready && refusal === null,
        button('PDB', () => this.save(entry, 'pdb'), {
          class: 'btn structure-pdb',
          title:
            refusal ??
            'The model as a PDB — an atom per bead, a chain per sequence, bonds along the fibre — which ' +
              'PyMOL and ChimeraX open and this app reads back',
        }),
      ),
    )
    return el('div', { class: 'ann-inline structure-actions' }, ...nodes)
  }
 
  private save(entry: AnnotationEntry, format: 'bed3d' | 'pdb'): void {
    const payload = entry.payload
    if (payload === undefined || payload.kind !== 'features') return
    const stamp = fileStamp(new Date())
    const created = this.options.createdAt?.(entry)
    const context = {
      label: entry.label,
      ...(entry.source.kind === 'computed' ? { file: entry.source.framing } : {}),
      ...(created === undefined ? {} : { created }),
    }
    const text = format === 'pdb' ? writePdb(payload.data, context) : writeBed3d(payload.data, context)
    void downloadBlob(new Blob([text], { type: 'text/plain' }), `${exportBaseName(entry.label)}-${stamp}.${format}`)
  }
 
  private swatch(entry: AnnotationEntry): HTMLElement {
    const input = el('input', {
      class: 'ann-swatch',
      type: 'color',
      value: entry.style.colour,
      title: `Colour for ${entry.label}`,
    })
    input.addEventListener('input', () => this.options.store.restyle(entry.id, { colour: input.value }))
    return input
  }
 
  private name(entry: AnnotationEntry): HTMLElement {
    const input = el('input', {
      class: 'input ann-name',
      type: 'text',
      value: entry.label,
      title: `${entry.label} — click to rename`,
    })
    input.addEventListener('change', () => {
      const label = input.value.trim()
      if (label === '' || label === entry.label) {
        input.value = entry.label
        return
      }
      this.options.store.update(entry.id, { label })
    })
    input.addEventListener('keydown', (event) => {
      // Enter that goes on to the next row's name, Shift+Tab the one before (§26.8).
      if (tabAmong(event, input, '.ann-list', 'input.ann-name')) return
      if (event.key === 'Enter') {
        event.preventDefault()
        input.blur()
      } else if (event.key === 'Escape') {
        event.preventDefault()
        input.value = entry.label
        input.blur()
      }
    })
    return input
  }
 
  private visibility(entry: AnnotationEntry): HTMLElement {
    // **The eye is the deck's cell**, not the row's `visible` flag: a model is
    // drawn in one place and one place only, so "hide" can only mean take it
    // out of there. On the way in it opens the cell; on the way out it closes
    // it, and a deck left holding nothing goes with it. It is the row's only
    // show/hide — see {@link actions} for the button that used to duplicate it.
    const shown = this.options.shown?.(entry.id) === true
    const ready = entry.status === 'ready' && entry.payload?.kind === 'features'
    const node = iconButton(
      shown ? 'eye' : 'eye-off',
      shown ? 'Hide the model: take its cell out of the deck' : 'Show the model in the deck, in a cell of its own',
      () => {
        if (shown) this.options.onHide?.(entry)
        else this.options.onShow?.(entry)
      },
      { class: 'btn btn-icon ann-eye', size: 14 },
    )
    node.setAttribute('aria-pressed', String(shown))
    node.disabled = !ready || this.options.onShow === undefined
    return node
  }
}
 
/** A button that does nothing until the row has a model to do it with. */
function disabledUnless(ready: boolean, node: HTMLButtonElement): HTMLButtonElement {
  if (!ready) node.disabled = true
  return node
}
 
/** `Model from the map` — the task that made a computed row, or nothing. */
function madeBy(entry: AnnotationEntry): string | null {
  if (entry.source.kind !== 'computed') return null
  return taskById(entry.source.task)?.title ?? entry.source.task
}
 
/** The line under the name: what the row holds, or why it holds nothing yet. */
function noteOf(entry: AnnotationEntry): string | null {
  if (entry.status === 'error') return entry.error ?? 'the structure could not be read'
  if (entry.status === 'loading') return 'reading…'
  if (entry.status === 'detached') {
    return entry.source.kind === 'computed'
      ? 'the model’s places were not carried across the reload'
      : 'the file has to be pointed at again'
  }
  if (entry.payload?.kind === 'features') return g3dSummary(entry.payload.data)
  return entry.summary ?? null
}
 
/**
 * The zone a structure is loaded through.
 *
 * Its own zone rather than the Tracks card's, because the sentence on it is
 * the point: a reader with a `.3dg` from Dip-C does not know it is a g3d, and
 * the Tracks card's list of eight formats does not say so. A file of any
 * other kind dropped here still goes where its bytes say (§8.3) — the loader
 * decides by content — so the zone accepts and the row lands where it should.
 *
 * **The card is the target and nothing under it.** There is no box to paste a
 * URL into, here or on any other card: the same rule the Tracks and Contact
 * maps cards were brought to (§5.11), and for the same reasons — a remote file
 * arrives by the address bar, by a session, or by the Files column (§20), and
 * a text box under every drop zone was three ways of saying *open something*
 * on one card. A structure held by URL still opens exactly as it did; only the
 * box is gone.
 */
function loadZone(loader: AnnotationLoader): HTMLElement[] {
  const choose = async (): Promise<void> => {
    const files = await chooseFiles({ multiple: true })
    if (files.length > 0) loader.addFiles(files)
  }
  const zone = el(
    'div',
    { class: 'ann-drop', attrs: { tabindex: '0', role: 'button' } },
    el('span', { class: 'ann-drop-label', text: 'Load a structure' }),
    el('span', {
      class: 'ann-drop-hint',
      // The formats, then the same last sentence the Tracks and Contact maps
      // zones say (§20.2). What it dropped is the rest: *drop one anywhere on
      // this card* is a gesture nobody was about to make while looking at a
      // rectangle drawn as the target, and *the deck's structure cell draws it*
      // is what happens next, in the internal words for it, on the one zone of
      // three that explained itself at that length.
      text:
        'A g3d (WashU, text or binary), a .3dg (Dip-C), a bed3d or a PDB this app wrote. Drop one ' +
        'here, or click to choose.',
    }),
  )
  zone.addEventListener('click', () => void choose())
  zone.addEventListener('keydown', (event) => {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault()
      void choose()
    }
  })
  return [zone]
}