All files / src/ui sequenceCopy.ts

73.91% Statements 68/92
78.94% Branches 15/19
83.33% Functions 5/6
73.91% Lines 68/92

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                                2x 2x     2x 2x 2x 2x     2x     2x                                   2x 3x 3x 3x     3x 3x 3x 2x 2x 2x 2x 2x 2x 3x 3x     1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x   3x 2x 2x 1x 1x 1x 1x 1x 3x 3x 3x 1x 1x     2x 2x 1x 2x 1x 1x             2x 3x 3x 3x 3x 3x     2x 1x 1x                                                              
/**
 * A sequence lane's residues as text, for the clipboard (§8.8).
 *
 * Two ways in, one reading: *Copy sequence* on a sequence lane's right-click
 * menu takes the stretch the lane is showing, and a drag across the residues or
 * across one reading frame takes the stretch dragged. Both end here, where the
 * bases are asked of the annotations worker — the same `bases` request the lane
 * draws from, in the chunks the worker answers whole — and turned into what is
 * on screen: the complement read backwards where the stretch is drawn reversed
 * (§27.2), and a frame's protein where a frame was dragged.
 *
 * Plain text, no FASTA header: what a primer tool, BLAST's box or a paper's
 * supplement takes pasted. The bases keep the file's case, since a soft-masked
 * repeat is lower-case for a reason.
 */
 
import { complementByte } from '../annotations/fasta'
import { translateFrame, type ReadingFrame } from '../annotations/codons'
import type { BaseRegion } from '../annotations/types'
import type { AnnotationClient } from '../annotations/worker/client'
import { strandOf, type LocusAxis } from '../render/locus'
import { fmt } from './dom'
import { copyTextToClipboard } from './export'
import { messageToast } from './toast'
 
/** The most a copy reads: ten megabases, a clipboard's worth and a few seconds of reading. */
export const SEQUENCE_COPY_LIMIT = 10_000_000
 
/** What one `bases` request answers whole (`annotations.worker.ts`'s `BASES_PER_REQUEST`). */
const CHUNK = 400_000
 
/** A stretch of one sequence, in the file's coordinates: 0-based, half-open. */
export interface SequenceStretch {
  contig: string
  start: number
  end: number
  /** Drawn the other way round, as a reversed piece of an edit is (§27.2). */
  reversed: boolean
}
 
/**
 * The stretch of one sequence between two places on an axis, or `null` where
 * they are on two sequences, or off the axis.
 *
 * Clamped to the axis first, so a view panned past a sequence's end still names
 * the part of it that is there.
 */
export function stretchBetween(axis: LocusAxis, from: number, to: number): SequenceStretch | null {
  const low = Math.max(axis.segments[0]?.unitStart ?? 0, Math.min(from, to))
  const high = Math.min(axis.unitEnd, Math.max(from, to))
  if (!(high > low)) return null
  // A hair inside the far end, which is the start of the next base — or of the
  // next sequence, on an axis of several.
  const a = axis.unitToLocus(low)
  const b = axis.unitToLocus(Math.max(low, high - 1e-6))
  if (a === null || b === null || a.contig !== b.contig) return null
  const segment = axis.segmentAt(low)
  return {
    contig: a.contig,
    start: Math.max(0, Math.floor(Math.min(a.bp, b.bp))),
    end: Math.ceil(Math.max(a.bp, b.bp)),
    reversed: segment !== null && strandOf(segment) === -1,
  }
}
 
/** A stretch's bases, read in the chunks the worker answers, or `null` where it answers nothing. */
export async function readStretch(
  client: AnnotationClient,
  entry: string,
  stretch: Pick<SequenceStretch, 'contig' | 'start' | 'end'>,
): Promise<BaseRegion | null> {
  const parts: Uint8Array[] = []
  let at = stretch.start
  while (at < stretch.end) {
    const end = Math.min(stretch.end, at + CHUNK)
    const got = await client.request<'bases'>({
      kind: 'bases',
      entry,
      regions: [{ contig: stretch.contig, start: at, end }],
    })
    const region = got.regions[0]
    if (region === undefined || region.end <= at) break
    parts.push(region.bases.subarray(at - region.start, Math.min(region.end, end) - region.start))
    // A shorter answer is the sequence's end: nothing past it to ask for.
    if (region.end < end) break
    at = end
  }
  if (parts.length === 0) return null
  const length = parts.reduce((sum, part) => sum + part.length, 0)
  const bases = new Uint8Array(length)
  let offset = 0
  for (const part of parts) {
    bases.set(part, offset)
    offset += part.length
  }
  return { contig: stretch.contig, start: stretch.start, end: stretch.start + length, bases }
}
 
/** The bases as text, read the way they are drawn: backwards and complemented on a reversed stretch. */
export function basesText(region: BaseRegion, reversed: boolean): string {
  if (!reversed) return new TextDecoder().decode(region.bases)
  const out = new Uint8Array(region.bases.length)
  for (let i = 0; i < out.length; i++) out[i] = complementByte(region.bases[region.bases.length - 1 - i])
  return new TextDecoder().decode(out)
}
 
/**
 * A stretch widened to whole codons of one frame: the first codon it touches to
 * the last. Frames are anchored to the sequence's first base (§8.8), so a
 * codon of frame ±k starts where `(position − (k − 1)) mod 3` is zero.
 */
export function codonStretch<T extends { start: number; end: number }>(stretch: T, frame: ReadingFrame): T {
  const phase = Math.abs(frame) - 1
  const start = stretch.start - ((((stretch.start - phase) % 3) + 3) % 3)
  const end = stretch.end + ((((phase - stretch.end) % 3) + 3) % 3)
  return { ...stretch, start: Math.max(0, start), end }
}
 
/** One frame's protein over a stretch of bases, as the strand it is read on runs. */
export function proteinText(region: BaseRegion, frame: ReadingFrame, code: number): string {
  return translateFrame(region, frame, code, region.start, region.end)
}
 
/**
 * Read a stretch and put it on the clipboard, saying so — or why not — in a
 * toast: a copy of ten megabases is a few seconds of reading, and a menu row
 * that closed with nothing said would look like one that did nothing.
 */
export async function copyStretch(
  client: AnnotationClient,
  entry: string,
  stretch: SequenceStretch,
  frame: { frame: ReadingFrame; code: number } | null = null,
): Promise<void> {
  const asked = frame === null ? stretch : codonStretch(stretch, frame.frame)
  try {
    const region = await readStretch(client, entry, asked)
    if (region === null) {
      messageToast('No sequence there to copy.')
      return
    }
    const text = frame === null ? basesText(region, stretch.reversed) : proteinText(region, frame.frame, frame.code)
    await copyTextToClipboard(text)
    messageToast(
      frame === null
        ? `Copied ${fmt.int(text.length)} bp of ${stretch.contig}`
        : `Copied ${fmt.int(text.length)} amino acids, frame ${frame.frame > 0 ? '+' : ''}${frame.frame}`,
    )
  } catch (err) {
    messageToast(`Could not copy the sequence: ${err instanceof Error ? err.message : String(err)}`)
  }
}