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 | 2x 2x 2x 2x 2x 2x 2x 2x 6x 6x 2x 23x 23x 23x 20x 20x 2x 2x 4x 4x 2x 8x 8x 8x 8x 8x 8x 8x 11x 4x 11x 11x 4x 4x | /**
* The sequence itself, drawn.
*
* A genome browser's sequence lane is the one track whose *representation*
* changes with the zoom rather than merely its resolution, and this module owns
* the top two rungs of that ladder:
*
* - a base is under a pixel wide — nothing legible can be drawn, and the lane
* shows GC content instead. That is not here: GC is a value per window, so it
* goes through `render/tracks.ts` like every other value track, which is what
* makes it autoscale, fill, flip and export with no special case (§8.4);
* - a base is a pixel or more — one coloured column per residue. At a whole gene
* this is a texture rather than a reading, and that texture is exactly right:
* AT-rich promoters and GC-rich exons are visible in it;
* - a base is wide enough for a glyph — the letter, in its own colour, over a
* pale wash of it. Which is the point the reader was zooming towards.
*
* There is no third drawing to keep in step with the other two: the transition
* is decided by one number, {@link basesAreDrawable}, and both the browser and
* its PDF export ask it the same question about the same frame.
*/
import { BASE_CODE, complementByte } from '../annotations/fasta'
import type { BaseRegion } from '../annotations/types'
import { laneRect, laneText, type LaneFrame } from './lane'
import { stretchOf, strandOf, unitAt, type LocusAxis } from './locus'
/**
* Base colours, indexed by {@link BASE_CODE}: A, C, G, T/U, anything else.
*
* The convention every genome browser shares — green, blue, orange, red — so a
* reader who knows one knows this. Darkened a little from the primaries, because
* these are drawn as filled columns at full height rather than as letters on
* white, and a screen of saturated primaries is a screen nobody can look at.
*/
export const BASE_COLOURS = ['#3d9950', '#2f6fb5', '#d59225', '#c8483f', '#8b939c'] as const
/** Below this many pixels per base, a column is thinner than the ink in it. */
export const COLUMN_MIN_WIDTH = 1
/** Below this, a letter does not fit between its neighbours. */
export const LETTER_MIN_WIDTH = 6
/** …and a lane shorter than this has no room for one across. */
const LETTER_MIN_THICKNESS = 12
/**
* `true` when this frame can draw one residue per column.
*
* The single decision behind the whole ladder, asked of the frame rather than of
* a zoom level so that the answer is the same for the screen and for the figure
* exported from it — and so that the lane, its readout and its tooltip cannot
* disagree about which of the two things is on screen.
*/
export function basesAreDrawable(frame: LaneFrame): boolean {
return basePixels(frame) >= COLUMN_MIN_WIDTH
}
/** How many pixels one base occupies in this frame. */
export function basePixels(frame: LaneFrame): number {
const [d0, d1] = frame.domain
const span = d1 - d0
if (!(span > 0)) return 0
return Math.abs(frame.alongTo - frame.alongFrom) / span
}
export interface SequencePaint {
font: string
opacity: number
/** Draw the letters where they fit; off leaves the columns bare. */
letters: boolean
}
/**
* Draw the residues of every window that has arrived.
*
* Each base is placed through the axis, like every other mark in this viewer
* (§8.4) — never by dividing the window into equal parts, which would be a
* second coordinate system and would drift from the ruler above it by a pixel
* at the far end of a chromosome.
*/
export function drawSequence(
ctx: CanvasRenderingContext2D,
frame: LaneFrame,
axis: LocusAxis,
regions: readonly BaseRegion[],
paint: SequencePaint,
): void {
const width = basePixels(frame)
if (!(width >= COLUMN_MIN_WIDTH)) return
const letters =
paint.letters && width >= LETTER_MIN_WIDTH && frame.thickness >= LETTER_MIN_THICKNESS
ctx.save()
ctx.globalAlpha = paint.opacity
ctx.font = paint.font
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const low = Math.min(frame.alongFrom, frame.alongTo) - width
const high = Math.max(frame.alongFrom, frame.alongTo) + width
for (const region of regions) {
// Every stretch of the sequence, each drawing the bases of its own piece:
// on an edited axis one original can be several pieces, some read backwards
// (§27.2), and a reversed piece shows the other strand — the complement,
// read right to left, which is what the edited sequence says there.
for (const segment of axis.segmentsOf(region.contig)) {
if (!(segment.bpPerUnit > 0)) continue
const [lowBp, highBp] = stretchOf(segment)
const reversed = strandOf(segment) === -1
for (let i = 0; i < region.bases.length; i++) {
const bp = region.start + i
if (bp < lowBp || bp >= highBp) continue
const a0 = frame.toPixel(unitAt(segment, bp))
const a1 = frame.toPixel(unitAt(segment, bp + 1))
const from = Math.min(a0, a1)
const to = Math.max(a0, a1)
if (to < low || from > high) continue
const byte = reversed ? complementByte(region.bases[i]) : region.bases[i]
drawBase(ctx, frame, paint, letters, byte, from, to)
}
}
}
ctx.restore()
}
/** One base's column and, where there is room, its letter. */
function drawBase(
ctx: CanvasRenderingContext2D,
frame: LaneFrame,
paint: SequencePaint,
letters: boolean,
byte: number,
from: number,
to: number,
): void {
const colour = BASE_COLOURS[BASE_CODE[byte]]
const [x, y, boxWidth, boxHeight] = laneRect(frame, from, to, 0, frame.thickness)
ctx.fillStyle = colour
// Under a letter the column is a wash rather than a block: the letter is
// what is being read at that zoom, and reading it off a saturated ground
// is harder than reading it off a tint of the same colour.
//
// And even bare, a column is not drawn at full strength. A lane of solid
// primaries is a wall: it takes over the picture from the tracks it is
// supposed to be annotating, and the lane's own edges disappear into it.
// At two thirds it is still four distinguishable colours and it still
// reads as a strip of a genome browser rather than as a test card.
ctx.globalAlpha = paint.opacity * (letters ? 0.22 : 0.62)
ctx.fillRect(x, y, boxWidth, boxHeight)
if (!letters) return
ctx.globalAlpha = paint.opacity
ctx.fillStyle = colour
laneText(ctx, frame, letterOf(byte), (from + to) / 2, frame.thickness / 2)
}
/** The residue as a glyph: upper case, whatever the file wrote. */
export function letterOf(byte: number): string {
return String.fromCharCode(byte >= 0x61 && byte <= 0x7a ? byte - 0x20 : byte)
}
/**
* The base at one position, for the readout.
*
* A linear scan of the windows in hand, which are the windows on screen: three
* of them at most, and a binary search over a `Uint8Array` whose start is known
* is arithmetic anyway.
*
* @param reversed The position is in a stretch drawn the other way round
* (§27.2), whose letters are the other strand's: the complement.
*/
export function baseAt(
regions: readonly BaseRegion[],
contig: string,
bp: number,
reversed = false,
): string | null {
const at = Math.floor(bp)
for (const region of regions) {
if (region.contig !== contig || at < region.start || at >= region.end) continue
const byte = region.bases[at - region.start]
return letterOf(reversed ? complementByte(byte) : byte)
}
return null
}
|