All files / src/annotations bases.ts

27.5% Statements 22/80
80% Branches 4/5
57.14% Functions 4/7
27.5% Lines 22/80

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                                                      2x             2x 2x   2x 2x   2x 90x   90x 90x   2x 65x 65x 65x     2x 38x       38x                 2x                                                                                                                                 2x 32x 32x           2x         2x                        
/**
 * The residues a view is drawing, kept between frames.
 *
 * {@link SampleCache}'s sibling, and it exists for exactly the same reason: §2
 * says a pan performs no read, so what is fetched has to be wider than the view
 * and has to be re-used until the view leaves it. What it does *not* need is the
 * other half of that machinery — a resolution ladder. Bases have one resolution.
 * A window is either drawn base by base or it is not drawn as bases at all, and
 * the second case is the GC line, which goes through `SampleCache` like every
 * other value track.
 *
 * That is the whole difference between the two, and it is why this is thirty
 * lines rather than a parameter on the other one.
 */
 
import type { LocusAxis } from '../render/locus'
import type { BaseRegion } from './types'
import type { AnnotationClient } from './worker/client'
 
/**
 * How much wider than the view the residues are fetched.
 *
 * A whole view on each side rather than the half `SampleCache` uses. Letters are
 * only ever drawn a few thousand at a time — three screens of them is a few
 * kilobytes — and at this zoom a reader pans by dragging across the window
 * repeatedly, which is precisely the gesture a narrow margin makes stutter.
 */
const FETCH_PAD = 1
 
interface Held {
  covered: Map<string, [number, number]>
  regions: BaseRegion[]
}
 
export class BaseCache {
  private readonly held = new Map<string, Held>()
  /** The window on its way per key, with how far its read has got. */
  private readonly inflight = new Map<string, { done: number; total: number } | null>()
  private disposed = false
 
  constructor(
    private readonly client: AnnotationClient,
    /** Ask for a repaint; the caller coalesces it into its own frame. */
    private readonly onChange: () => void,
  ) {}
 
  destroy(): void {
    this.disposed = true
    this.held.clear()
  }
 
  /** Drop what belongs to entries that are no longer loaded. */
  prune(live: ReadonlySet<string>): void {
    if (this.held.size === 0) return
    for (const key of [...this.held.keys()]) {
      if (!live.has(key.slice(0, key.lastIndexOf(':')))) this.held.delete(key)
    }
  }
 
  /**
   * The residues to draw, and a fetch if the ones in hand do not reach.
   *
   * Returns whatever is held and never waits, exactly as the samples do: a lane
   * drawing slightly stale letters for one frame is invisible, and a lane that
   * blanks while it waits is a flicker on every drag.
   */
  basesFor(options: {
    entry: string
    scope: string
    axis: LocusAxis
    domain: readonly [number, number]
  }): BaseRegion[] | null {
    const key = `${options.entry}:${options.scope}`
    const cached = this.held.get(key) ?? null
    const view = options.axis.windows(options.domain)
    if (view.length === 0) return cached?.regions ?? null
    if (cached !== null && covers(cached.covered, view)) return cached.regions
    if (this.inflight.has(key)) return cached?.regions ?? null
 
    const wanted = options.axis.windows(options.domain, FETCH_PAD)
    this.inflight.set(key, null)
    void this.client
      .request<'bases'>(
        {
          kind: 'bases',
          entry: options.entry,
          regions: wanted.map((window) => ({
            contig: window.contig,
            start: window.start,
            end: window.end,
          })),
        },
        (done, total) => {
          if (this.disposed || !this.inflight.has(key)) return
          this.inflight.set(key, { done, total })
          this.onChange()
        },
      )
      .then((result) => {
        if (this.disposed) return
        const covered = new Map<string, [number, number]>()
        // What was *asked for*, not what came back — and deliberately, though it
        // is the weaker-looking of the two. A reply carries nothing for a contig
        // the file has never heard of, so believing the reply would leave this
        // window permanently uncovered and refetch it on every frame for as long
        // as the reader looked at it. The other failure this trades against is a
        // reply truncated by the worker's cap, which would then be drawn short
        // and silently: that cap is four hundred thousand residues and a request
        // from here is a few thousand, since letters are only ever asked for at
        // a zoom where one fills a pixel. The reading frames' ticks ask from
        // further out (§8.8) — three screens at 24 bases a pixel, under a
        // hundred and fifty thousand on a 2000-pixel lane — and still under it.
        for (const window of wanted) covered.set(window.contig, [window.start, window.end])
        this.held.set(key, { covered, regions: result.regions })
        this.onChange()
      })
      .catch(() => {
        // The card already reports a load failure; the lane keeps what it had
        // and the next view change tries again.
      })
      .finally(() => {
        this.inflight.delete(key)
        // A failed read asked for no frame, and the lane is done waiting on it
        // (`SampleCache`'s own `finally` says why that matters).
        if (!this.disposed) this.onChange()
      })
 
    return cached?.regions ?? null
  }
 
  /** Whether residues are on their way for any lane, as `SampleCache.busy` says of samples. */
  get busy(): boolean {
    return this.inflight.size > 0
  }
 
  /**
   * The read in flight for this entry and scope, or `null`: the letters' half
   * of `SampleCache.pendingOf`, for the same bar under the same name.
   */
  pendingOf(entry: string, scope: string): { done: number; total: number } | null {
    const key = `${entry}:${scope}`
    if (!this.inflight.has(key)) return null
    return this.inflight.get(key) ?? { done: 0, total: 0 }
  }
}
 
function covers(
  covered: Map<string, [number, number]>,
  windows: Array<{ contig: string; start: number; end: number }>,
): boolean {
  for (const window of windows) {
    const span = covered.get(window.contig)
    if (span === undefined || span[0] > window.start || span[1] < window.end) return false
  }
  return true
}