All files / src/edit grid.ts

94.02% Statements 173/184
83.33% Branches 65/78
100% Functions 12/12
94.02% Lines 173/184

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                                            2x 2x                                                                                                 2x 2x   90x 90x 90x   90x   90x 90x   2x             90x 90x 90x 90x 90x 90x 90x 90x     2x 90x 90x 90x         90x 534x   90x 90x 90x 90x 241x 241x 241x 241x 268x 268x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 267x 267x 268x             267x 267x 267x 268x 18489x 18489x 267x 267x 267x   267x 267x 267x 267x     267x 267x 267x 267x 267x 267x 267x 268x 268x 241x 241x 241x 241x 241x 241x 241x 241x 241x 241x 90x 90x 90x 90x 90x 90x 90x 90x 90x     2x 5x 3x 3x 3x 5x 5x     2x 1120x 1120x 1120x 1120x 2499x 2499x 2499x 1635x 1120x 2499x   1120x     2x 29x 26x 26x 29x 37x 37x 37x 29x 26x 37x   29x     2x 295x 295x             2x 913x 913x 913x 913x 1x 1x 1x 912x 913x 913x 913x             2x 44x 44x 44x 44x 28x 31x 31x 31x 27x 31x       27x 31x 31x 31x   44x     2x 66x 66x 254x 26x 26x 26x 254x 66x 66x     2x 2x 2x 2x 2x 2x  
/**
 * The lens at the picture's grid (SPECS §27.2, §27.9): which cell of the
 * picture every cell of the edited axis shows.
 *
 * The picture is held in the file's order (`metacooler/picture.ts`) and never
 * changes; an edit changes this, and only this. Each piece is placed on the
 * grid by rounding its two ends to the nearest cell edge — **bins move whole**,
 * so every cell inside a piece is a cell of the picture, and what is
 * approximate is the junction, at most a cell of it (§27.2) — then read forward
 * or backwards, and a gap is cells that show nothing. What comes out is one
 * number per cell of the edited axis ({@link LensGrid.cellMap}), which is all
 * the painter needs, and one stretch of the axis per piece
 * ({@link LensGrid.axis}), which is all the lanes need: they fetch in original
 * coordinates and draw through it, as §27.7 has them.
 *
 * The edited positions the stage speaks — a cut *here*, this stretch moved
 * *there* — are base pairs of edited sequences, the coordinates a step is
 * recorded in. {@link LensGrid.place} and {@link LensGrid.unitOf} are the two
 * directions between those and the axis, and a junction is exact both ways:
 * the edge of a cell there is the base pair the parts meet at.
 */
 
import { isGap, type EditedAssembly, type Piece } from '../genome/edit'
import { LocusAxis, bpAt, unitAt, type LocusSegment } from '../render/locus'
 
/** The picture's grid over the original assembly, as the reader reported it. */
export interface GridLayout {
  /** Base pairs a cell a side. */
  binsize: number
  /** The original sequences, in file order, each with the cells it spans. */
  sequences: ReadonlyArray<{ name: string; length: number; firstCell: number; cells: number }>
}
 
/** One part of an edited sequence, placed on the edited axis. */
export interface GridPart {
  /** The edited sequence it is in, and its place in that sequence's parts. */
  sequence: string
  index: number
  /** Where it begins in its sequence, and how long it is, in base pairs. */
  offset: number
  length: number
  /** Its cells on the edited axis, half-open. Equal where it is too short for one. */
  from: number
  to: number
  /** The piece, or `null` for a gap. */
  piece: Piece | null
  /** The original sequence's place in the file, for its colour; `-1` for a gap. */
  original: number
  /** The stretch of the axis the lanes are drawn through; `null` for a gap or an empty part. */
  segment: LocusSegment | null
}
 
/** One edited sequence on the axis. */
export interface GridSequence {
  name: string
  /** In base pairs. */
  length: number
  /** Its cells, half-open. */
  from: number
  to: number
  /** Its parts in {@link LensGrid.parts}, half-open. */
  partFrom: number
  partTo: number
}
 
/** An edited position: a base pair of an edited sequence, and the part it is in. */
export interface EditedLocus {
  sequence: string
  bp: number
  part: GridPart
}
 
export class LensGrid {
  readonly binsize: number
  /** Cells on the edited axis. */
  readonly n: number
  readonly parts: readonly GridPart[]
  readonly sequences: readonly GridSequence[]
  /** The picture's cell each edited cell shows; `-1` in a gap. */
  readonly cellMap: Int32Array
  /** The picture's cell count a side, so a caller can check a map against it. */
  readonly pictureCells: number
  private axisHeld: LocusAxis | null | undefined
 
  private constructor(fields: {
    binsize: number
    n: number
    parts: GridPart[]
    sequences: GridSequence[]
    cellMap: Int32Array
    pictureCells: number
  }) {
    this.binsize = fields.binsize
    this.n = fields.n
    this.parts = fields.parts
    this.sequences = fields.sequences
    this.cellMap = fields.cellMap
    this.pictureCells = fields.pictureCells
  }
 
  /** Place an edited assembly on the picture's grid. */
  static of(assembly: EditedAssembly, layout: GridLayout): LensGrid {
    const size = layout.binsize
    const byName = new Map(layout.sequences.map((sequence, index) => [sequence.name, { ...sequence, index }]))
    const pictureCells = layout.sequences.reduce((total, sequence) => Math.max(total, sequence.firstCell + sequence.cells), 0)
 
    // Where a base pair of an original sequence falls among its cells: the
    // nearest edge, and the sequence's own last edge for its end — its last
    // cell is short in general, and rounding would drop it.
    const edge = (cells: number, length: number, bp: number): number =>
      bp >= length ? cells : Math.max(0, Math.min(cells, Math.round(bp / size)))
 
    const parts: GridPart[] = []
    const sequences: GridSequence[] = []
    const map: number[] = []
    for (const sequence of assembly.sequences) {
      const first = map.length
      const partFrom = parts.length
      let offset = 0
      sequence.parts.forEach((part, index) => {
        const from = map.length
        if (isGap(part)) {
          const cells = Math.round(part.gap / size)
          for (let k = 0; k < cells; k++) map.push(-1)
          parts.push({
            sequence: sequence.name,
            index,
            offset,
            length: part.gap,
            from,
            to: map.length,
            piece: null,
            original: -1,
            segment: null,
          })
          offset += part.gap
          return
        }
        const source = byName.get(part.sequence)
        const length = part.end - part.start
        if (source === undefined) {
          // A piece of a sequence the picture does not have: the edit was made
          // on another file, which the caller refuses before it gets here.
          parts.push({ sequence: sequence.name, index, offset, length, from, to: from, piece: part, original: -1, segment: null })
          offset += length
          return
        }
        const low = edge(source.cells, source.length, part.start)
        const high = edge(source.cells, source.length, part.end)
        const cells = Math.max(0, high - low)
        for (let k = 0; k < cells; k++) {
          map.push(part.strand === 1 ? source.firstCell + low + k : source.firstCell + high - 1 - k)
        }
        const to = map.length
        const segment: LocusSegment | null =
          cells === 0
            ? null
            : {
                contig: part.sequence,
                unitStart: from,
                unitEnd: to,
                // The left edge of the first cell: the piece's low end read
                // forward, its high end read backwards.
                bpStart: (part.strand === 1 ? low : high) * size,
                bpPerUnit: size,
                contigLength: source.length,
                strand: part.strand,
                bpFrom: part.start,
                bpTo: part.end,
              }
        parts.push({ sequence: sequence.name, index, offset, length, from, to, piece: part, original: source.index, segment })
        offset += length
      })
      sequences.push({
        name: sequence.name,
        length: offset,
        from: first,
        to: map.length,
        partFrom,
        partTo: parts.length,
      })
    }
    return new LensGrid({
      binsize: size,
      n: map.length,
      parts,
      sequences,
      cellMap: Int32Array.from(map),
      pictureCells,
    })
  }
 
  /** The stretches the lanes are drawn through, one per piece; `null` with no cell to draw on. */
  get axis(): LocusAxis | null {
    if (this.axisHeld === undefined) {
      const segments = this.parts.flatMap((part) => (part.segment === null ? [] : [part.segment]))
      this.axisHeld = segments.length === 0 ? null : new LocusAxis(segments)
    }
    return this.axisHeld
  }
 
  /** The part a unit of the axis is in — never one too short for a cell — or `null` outside. */
  partAt(unit: number): GridPart | null {
    if (!(unit >= 0 && unit < this.n)) return null
    let low = 0
    let high = this.parts.length - 1
    while (low <= high) {
      const mid = (low + high) >> 1
      const part = this.parts[mid]
      if (unit < part.from) high = mid - 1
      else if (unit >= part.to) low = mid + 1
      else return part
    }
    return null
  }
 
  /** The sequence a unit of the axis is in, or `null` outside. */
  sequenceAt(unit: number): GridSequence | null {
    if (!(unit >= 0 && unit < this.n)) return null
    let low = 0
    let high = this.sequences.length - 1
    while (low <= high) {
      const mid = (low + high) >> 1
      const sequence = this.sequences[mid]
      if (unit < sequence.from) high = mid - 1
      else if (unit >= sequence.to) low = mid + 1
      else return sequence
    }
    return null
  }
 
  /** A sequence by name, or `null`. */
  sequence(name: string): GridSequence | null {
    return this.sequences.find((sequence) => sequence.name === name) ?? null
  }
 
  /**
   * The edited position at a unit of the axis: the base pair of the original
   * under it, carried into the edited sequence — so a place named on the map is
   * the place a track under it names — and in a gap, the fraction of the gap.
   */
  place(unit: number): EditedLocus | null {
    const part = this.partAt(unit)
    if (part === null) return null
    const piece = part.piece
    if (piece === null || part.segment === null) {
      const fraction = part.to > part.from ? (unit - part.from) / (part.to - part.from) : 0
      return { sequence: part.sequence, bp: part.offset + fraction * part.length, part }
    }
    const original = Math.max(piece.start, Math.min(piece.end, bpAt(part.segment, unit)))
    const within = piece.strand === 1 ? original - piece.start : piece.end - original
    return { sequence: part.sequence, bp: part.offset + within, part }
  }
 
  /**
   * An edited position as a unit of the axis: where a step recorded in base
   * pairs is drawn. Exact at a junction — the edge of the cell where its part
   * begins — and at a sequence's end. `null` for a name the assembly lacks.
   */
  unitOf(sequence: string, bp: number): number | null {
    const found = this.sequence(sequence)
    if (found === null) return null
    if (bp <= 0) return found.from
    if (bp >= found.length) return found.to
    for (let i = found.partFrom; i < found.partTo; i++) {
      const part = this.parts[i]
      if (bp < part.offset || bp >= part.offset + part.length) continue
      if (bp === part.offset) return part.from
      const piece = part.piece
      if (piece === null || part.segment === null) {
        const fraction = (bp - part.offset) / part.length
        return part.from + fraction * (part.to - part.from)
      }
      const within = bp - part.offset
      const original = piece.strand === 1 ? piece.start + within : piece.end - within
      return Math.max(part.from, Math.min(part.to, unitAt(part.segment, original)))
    }
    return found.to
  }
 
  /** Every edge between two parts of one sequence, in units, ascending and each once. */
  junctions(): number[] {
    const out: number[] = []
    for (const sequence of this.sequences) {
      for (let i = sequence.partFrom + 1; i < sequence.partTo; i++) {
        const at = this.parts[i].from
        if (at > sequence.from && at < sequence.to && out[out.length - 1] !== at) out.push(at)
      }
    }
    return out
  }
 
  /** Every edge between two sequences, and the axis's two ends, in units. */
  boundaries(): number[] {
    const out = [0]
    for (const sequence of this.sequences) if (out[out.length - 1] !== sequence.to) out.push(sequence.to)
    return out
  }
}