@gmod/cram
Version:
read CRAM files with pure Javascript
496 lines • 18.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CramRecord = exports.MateFlagsDecoder = exports.CramFlagsDecoder = exports.BamFlagsDecoder = exports.MateFlags = exports.CramFlags = exports.BamFlags = exports.defaultDecodeOptions = void 0;
const constants_ts_1 = __importDefault(require("./constants.js"));
const util_ts_1 = require("./util.js");
// precomputed pair orientation strings indexed by ((flags >> 4) & 0xF) | (isize > 0 ? 16 : 0)
// bits 0-3 encode flag bits 0x10(reverse),0x20(mate reverse),0x40(read1),0x80(read2)
// bit 4 encodes whether isize > 0
// prettier-ignore
const PAIR_ORIENTATION_TABLE = [
'F F ', 'F R ', 'R F ', 'R R ', 'F2F1', 'F2R1', 'R2F1', 'R2R1',
'F1F2', 'F1R2', 'R1F2', 'R1R2', 'F2F1', 'F2R1', 'R2F1', 'R2R1',
'F F ', 'R F ', 'F R ', 'R R ', 'F1F2', 'R1F2', 'F1R2', 'R1R2',
'F2F1', 'R2F1', 'F2R1', 'R2R1', 'F1F2', 'R1F2', 'F1R2', 'R1R2',
];
exports.defaultDecodeOptions = {
decodeTags: true,
};
function decodeReadSequence(cramRecord, refRegion) {
// if it has no length, it has no sequence
if (!cramRecord.lengthOnRef && !cramRecord.readLength) {
return null;
}
if (cramRecord.isUnknownBases()) {
return null;
}
// remember: all coordinates are 1-based closed
const regionSeqOffset = cramRecord.alignmentStart - refRegion.start;
if (!cramRecord.readFeatures) {
return refRegion.seq
.slice(regionSeqOffset, regionSeqOffset + (cramRecord.lengthOnRef || 0))
.toUpperCase();
}
// Walk read features against the reference to reconstruct the read sequence.
// See CRAMv3 §10.2 (Read features): https://samtools.github.io/hts-specs/CRAMv3.pdf
let bases = '';
let regionPos = regionSeqOffset;
let currentReadFeature = 0;
while (bases.length < cramRecord.readLength) {
if (currentReadFeature < cramRecord.readFeatures.length) {
const feature = cramRecord.readFeatures[currentReadFeature];
if (feature.code === 'Q' || feature.code === 'q') {
currentReadFeature += 1;
}
else if (feature.pos === bases.length + 1) {
currentReadFeature += 1;
switch (feature.code) {
case 'b': {
bases += feature.data;
regionPos += feature.data.length;
break;
}
case 'B': {
bases += feature.data[0];
regionPos += 1;
break;
}
case 'X': {
bases += feature.sub;
regionPos += 1;
break;
}
case 'I':
case 'i':
case 'S': {
bases += feature.data;
break;
}
case 'D':
case 'N': {
regionPos += feature.data;
break;
}
// H (hard clip), P (padding): do nothing
}
}
else {
// put down a chunk of reference up to the next read feature
const chunk = refRegion.seq.slice(regionPos, regionPos + feature.pos - bases.length - 1);
bases += chunk;
regionPos += chunk.length;
}
}
else {
// put down a chunk of reference up to the full read length
const chunk = refRegion.seq.slice(regionPos, regionPos + cramRecord.readLength - bases.length);
bases += chunk;
regionPos += chunk.length;
}
}
return bases.toUpperCase();
}
const baseNumbers = {
a: 0,
A: 0,
c: 1,
C: 1,
g: 2,
G: 2,
t: 3,
T: 3,
n: 4,
N: 4,
};
function decodeBaseSubstitution(cramRecord, refRegion, compressionScheme, readFeature) {
// decode base substitution code using the substitution matrix
const refCoord = readFeature.refPos - refRegion.start;
const refBase = refRegion.seq.charAt(refCoord);
if (refBase) {
readFeature.ref = refBase;
}
let baseNumber = baseNumbers[refBase];
if (baseNumber === undefined) {
baseNumber = 4;
}
const substitutionScheme = compressionScheme.substitutionMatrix[baseNumber];
const base = substitutionScheme[readFeature.data];
if (base) {
readFeature.sub = base;
}
}
exports.BamFlags = [
[0x1, 'Paired'],
[0x2, 'ProperlyPaired'],
[0x4, 'SegmentUnmapped'],
[0x8, 'MateUnmapped'],
[0x10, 'ReverseComplemented'],
// the mate is mapped to the reverse strand
[0x20, 'MateReverseComplemented'],
// this is read1
[0x40, 'Read1'],
// this is read2
[0x80, 'Read2'],
// not primary alignment
[0x100, 'Secondary'],
// QC failure
[0x200, 'FailedQc'],
// optical or PCR duplicate
[0x400, 'Duplicate'],
// supplementary alignment
[0x800, 'Supplementary'],
];
exports.CramFlags = [
[0x1, 'PreservingQualityScores'],
[0x2, 'Detached'],
[0x4, 'WithMateDownstream'],
[0x8, 'DecodeSequenceAsStar'],
];
exports.MateFlags = [
[0x1, 'OnNegativeStrand'],
[0x2, 'Unmapped'],
];
function makeFlagsHelper(x) {
const r = {};
for (const [code, name] of x) {
r[`is${name}`] = (flags) => !!(flags & code);
r[`set${name}`] = (flags) => flags | code;
}
return r;
}
exports.BamFlagsDecoder = makeFlagsHelper(exports.BamFlags);
exports.CramFlagsDecoder = makeFlagsHelper(exports.CramFlags);
exports.MateFlagsDecoder = makeFlagsHelper(exports.MateFlags);
/**
* Class of each CRAM record returned by this API.
*/
class CramRecord {
tags;
flags;
cramFlags;
readBases;
_refRegion;
readFeatures;
alignmentStart;
lengthOnRef;
readLength;
// templateLength is computed post-hoc for intra-slice mate pairs,
// templateSize is the raw CRAM-encoded TS data series value
templateLength;
templateSize;
_readName;
_readNameRaw;
_syntheticReadName;
mateRecordNumber;
mate;
uniqueId;
sequenceId;
readGroupId;
mappingQuality;
qualityScores;
get readName() {
if (this._readName === undefined) {
if (this._readNameRaw) {
this._readName = (0, util_ts_1.readNullTerminatedStringFromBuffer)(this._readNameRaw);
this._readNameRaw = undefined;
}
else {
return this._syntheticReadName;
}
}
return this._readName;
}
constructor({ flags, cramFlags, readLength, mappingQuality, lengthOnRef, qualityScores, mateRecordNumber, readBases, readFeatures, mate, readGroupId, readNameRaw, sequenceId, uniqueId, templateSize, alignmentStart, tags, }) {
this.flags = flags;
this.cramFlags = cramFlags;
this.readLength = readLength;
this.mappingQuality = mappingQuality;
this.lengthOnRef = lengthOnRef;
this.qualityScores = qualityScores;
this.readGroupId = readGroupId;
this.sequenceId = sequenceId;
this.uniqueId = uniqueId;
this.alignmentStart = alignmentStart;
this.tags = tags;
if (readNameRaw) {
this._readNameRaw = readNameRaw;
}
if (readBases) {
this.readBases = readBases;
}
this.templateSize = templateSize;
if (readFeatures) {
this.readFeatures = readFeatures;
}
if (mate) {
this.mate = mate;
}
if (mateRecordNumber) {
this.mateRecordNumber = mateRecordNumber;
}
}
/**
* Get a single quality score at the given index.
* @param index 0-based index into the quality scores
* @returns the quality score at that index, or undefined if not available
*/
qualityScoreAt(index) {
return this.qualityScores?.[index];
}
// BAM flags — see SAM/BAM spec §1.4 (Flag field):
// https://samtools.github.io/hts-specs/SAMv1.pdf
/** @returns {boolean} true if the read is paired, regardless of whether both segments are mapped */
isPaired() {
return !!(this.flags & constants_ts_1.default.BAM_FPAIRED);
}
/** @returns {boolean} true if the read is paired, and both segments are mapped */
isProperlyPaired() {
return !!(this.flags & constants_ts_1.default.BAM_FPROPER_PAIR);
}
/** @returns {boolean} true if the read itself is unmapped; conflictive with isProperlyPaired */
isSegmentUnmapped() {
return !!(this.flags & constants_ts_1.default.BAM_FUNMAP);
}
/** @returns {boolean} true if the mate is unmapped; conflictive with isProperlyPaired */
isMateUnmapped() {
return !!(this.flags & constants_ts_1.default.BAM_FMUNMAP);
}
/** @returns {boolean} true if the read is mapped to the reverse strand */
isReverseComplemented() {
return !!(this.flags & constants_ts_1.default.BAM_FREVERSE);
}
/** @returns {boolean} true if the mate is mapped to the reverse strand */
isMateReverseComplemented() {
return !!(this.flags & constants_ts_1.default.BAM_FMREVERSE);
}
isRead1() {
return !!(this.flags & constants_ts_1.default.BAM_FREAD1);
}
isRead2() {
return !!(this.flags & constants_ts_1.default.BAM_FREAD2);
}
isSecondary() {
return !!(this.flags & constants_ts_1.default.BAM_FSECONDARY);
}
isFailedQc() {
return !!(this.flags & constants_ts_1.default.BAM_FQCFAIL);
}
isDuplicate() {
return !!(this.flags & constants_ts_1.default.BAM_FDUP);
}
isSupplementary() {
return !!(this.flags & constants_ts_1.default.BAM_FSUPPLEMENTARY);
}
// CRAM-specific compression flags — see CRAMv3 §8.4 (Bit Flags):
// https://samtools.github.io/hts-specs/CRAMv3.pdf
isDetached() {
return !!(this.cramFlags & constants_ts_1.default.CRAM_FLAG_DETACHED);
}
hasMateDownStream() {
return !!(this.cramFlags & constants_ts_1.default.CRAM_FLAG_MATE_DOWNSTREAM);
}
isPreservingQualityScores() {
return !!(this.cramFlags & constants_ts_1.default.CRAM_FLAG_PRESERVE_QUAL_SCORES);
}
isUnknownBases() {
return !!(this.cramFlags & constants_ts_1.default.CRAM_FLAG_NO_SEQ);
}
/**
* Get the original sequence of this read.
* @returns {String} sequence basepairs
*/
getReadBases() {
if (!this.readBases && this._refRegion) {
const decoded = decodeReadSequence(this, this._refRegion);
if (decoded) {
this.readBases = decoded;
}
}
return this.readBases;
}
/**
* Get the CIGAR string describing this read's alignment against the
* reference, reconstructed from the read features. Substitutions and
* verbatim bases are reported as alignment matches (M), following the plain
* CIGAR convention where M covers both matches and mismatches. Unmapped
* reads return '*'.
*
* See CRAMv3 §10.2 (Read features):
* https://samtools.github.io/hts-specs/CRAMv3.pdf
*
* @returns {string} the CIGAR string, e.g. "50M2I48M"
*/
getCigarString() {
if (this.isSegmentUnmapped()) {
return '*';
}
// build up (length, op) pairs, merging adjacent runs of the same op so
// e.g. consecutive single-base insertions collapse into one I operation
const ops = [];
const push = (len, op) => {
if (len > 0) {
const last = ops.at(-1);
if (last?.[1] === op) {
last[0] += len;
}
else {
ops.push([len, op]);
}
}
};
let readConsumed = 0;
let refPos = this.alignmentStart;
if (this.readFeatures !== undefined) {
for (const feature of this.readFeatures) {
// 'q'/'Q' carry only quality information; their refPos does not track
// the alignment geometry, so they must not perturb position tracking
if (feature.code !== 'q' && feature.code !== 'Q') {
// reference bases between the last position and this feature are matches
const gap = feature.refPos - refPos;
push(gap, 'M');
readConsumed += gap;
refPos = feature.refPos;
switch (feature.code) {
case 'b': {
// verbatim stretch of bases, aligned as matches
push(feature.data.length, 'M');
readConsumed += feature.data.length;
refPos += feature.data.length;
break;
}
case 'B':
case 'X': {
// single-base (substitution or base+quality), aligned as a match
push(1, 'M');
readConsumed += 1;
refPos += 1;
break;
}
case 'D':
case 'N': {
push(feature.data, feature.code);
refPos += feature.data;
break;
}
case 'I':
case 'S': {
push(feature.data.length, feature.code);
readConsumed += feature.data.length;
break;
}
case 'i': {
// single-base insertion
push(1, 'I');
readConsumed += 1;
break;
}
case 'P':
case 'H': {
push(feature.data, feature.code);
break;
}
}
}
}
}
// any read bases past the last feature are trailing matches
push(this.readLength - readConsumed, 'M');
return ops.map(([len, op]) => `${len}${op}`).join('');
}
// adapted from igv.js
// uses precomputed lookup table indexed by flag bits + isize sign.
// the BAM spec defines tlen as positive for the leftmost segment and
// negative for the rightmost, so isize > 0 reliably indicates which
// read comes first without needing position-based correction
// (see also: gmod/bam-js src/record.ts pair_orientation getter)
getPairOrientation() {
const f = this.flags;
// paired (0x1) set, unmapped (0x4) clear, mate unmapped (0x8) clear
if ((f & 0xd) !== 0x1 || this.sequenceId !== this.mate?.sequenceId) {
return undefined;
}
const isize = this.templateLength || this.templateSize || 0;
return PAIR_ORIENTATION_TABLE[((f >> 4) & 0xf) | (isize > 0 ? 16 : 0)];
}
/**
* Annotates this feature with the given reference sequence basepair
* information. This will add a `sub` and a `ref` item to base
* substitution read features given the actual substituted and reference
* base pairs, and will make the `getReadBases()` method work.
*
* @param {object} refRegion
* @param {number} refRegion.start
* @param {number} refRegion.end
* @param {string} refRegion.seq
* @param {CramContainerCompressionScheme} compressionScheme
* @returns {undefined} nothing
*/
addReferenceSequence(refRegion, compressionScheme) {
if (this.readFeatures) {
// use the reference bases to decode the bases substituted in each base
// substitution
for (const readFeature of this.readFeatures) {
if (readFeature.code === 'X') {
decodeBaseSubstitution(this, refRegion, compressionScheme, readFeature);
}
}
}
// if this region completely covers this read,
// keep a reference to it
if (!this.readBases &&
refRegion.start <= this.alignmentStart &&
refRegion.end >=
this.alignmentStart + (this.lengthOnRef || this.readLength) - 1) {
this._refRegion = refRegion;
}
}
// Serializer used by snapshot tests and consumers that JSON.stringify a
// record. qualityScores (Uint8Array) is converted to number[] so snapshots
// stay diffable. Optional fields are added only when defined to match the
// historical shape of the output.
toJSON() {
const data = {
alignmentStart: this.alignmentStart,
cramFlags: this.cramFlags,
flags: this.flags,
readGroupId: this.readGroupId,
readLength: this.readLength,
sequenceId: this.sequenceId,
tags: this.tags,
uniqueId: this.uniqueId,
readName: this.readName,
readBases: this.getReadBases(),
qualityScores: this.qualityScores
? Array.from(this.qualityScores)
: this.qualityScores,
};
if (this.lengthOnRef !== undefined) {
data.lengthOnRef = this.lengthOnRef;
}
if (this.mappingQuality !== undefined) {
data.mappingQuality = this.mappingQuality;
}
if (this.templateSize !== undefined) {
data.templateSize = this.templateSize;
}
if (this.templateLength !== undefined) {
data.templateLength = this.templateLength;
}
if (this.readFeatures !== undefined) {
data.readFeatures = this.readFeatures;
}
if (this.mate !== undefined) {
data.mate = this.mate;
}
if (this.mateRecordNumber !== undefined) {
data.mateRecordNumber = this.mateRecordNumber;
}
return data;
}
}
exports.default = CramRecord;
exports.CramRecord = CramRecord;
//# sourceMappingURL=record.js.map