@gmod/cram
Version:
read CRAM files with pure Javascript
158 lines • 7.21 kB
JavaScript
import CramFile from "./cramFile/index.js";
function requireReadName(record) {
const name = record.readName;
if (name === undefined) {
throw new Error('readName undefined');
}
return name;
}
export default class IndexedCramFile {
cram;
index;
/**
*
* @param {object} args
* @param {Index-like} args.index object that supports
* getEntriesForRange(seqId,start,end) -> Promise[Array[index entries]]
*
* @param {CramFile} [args.cram] pre-constructed CramFile. If omitted,
* provide cramPath, cramUrl, or cramFilehandle instead.
*
* @param {string} [args.cramPath] local file path to the CRAM file
* @param {string} [args.cramUrl] remote URL of the CRAM file
* @param {FileHandle} [args.cramFilehandle] generic-filehandle2 or similar
*
* @param {Function} [args.seqFetch] async (seqId, start, end) => string
* returning reference sequence for a region; seqId is numeric, coords 1-based
*
* @param {number} [args.cacheSize] optional maximum number of CRAM records
* to cache. default 20,000
*
* @param {boolean} [args.checkSequenceMD5] - default true. if false,
* disables verifying the MD5 checksum of the reference sequence underlying a
* slice. In some applications, this check can cause an inconvenient amount
* (many megabases) of sequences to be fetched.
*/
constructor(args) {
this.cram =
args.cram ??
new CramFile({
url: args.cramUrl,
path: args.cramPath,
filehandle: args.cramFilehandle,
seqFetch: args.seqFetch,
checkSequenceMD5: args.checkSequenceMD5,
validateChecksums: args.validateChecksums,
cacheSize: args.cacheSize,
});
this.index = args.index;
}
/**
*
* @param seq numeric ID of the reference sequence
* @param start start of the range of interest. 1-based closed coordinates.
* @param end end of the range of interest. 1-based closed coordinates.
*/
async getRecordsForRange(seq, start, end, opts = {}) {
const viewAsPairs = opts.viewAsPairs ?? false;
const pairAcrossChr = opts.pairAcrossChr ?? false;
const maxInsertSize = opts.maxInsertSize ?? 200000;
const onProgress = opts.onProgress;
const decodeOptions = {
decodeTags: opts.decodeTags,
};
const seqId = seq;
// the .crai index downloads lazily here on first query; thread onProgress so
// its download streams under the same bar, ahead of the slice data below
const slices = await this.index.getEntriesForRange(seqId, start, end, {
onProgress,
});
let totalBytes = 0;
for (const slice of slices) {
totalBytes += slice.sliceBytes;
}
let downloadedBytes = 0;
onProgress?.(0, totalBytes);
// fetch all the slices and parse the feature data
const sliceResults = await Promise.all(slices.map(slice => this.getRecordsInSlice(slice, feature => {
// Check if feature belongs to this sequence
if (feature.sequenceId !== seq) {
return false;
}
// For unmapped reads (lengthOnRef is undefined), they are placed at their
// mate's position. Include them if that position is within the range.
if (feature.lengthOnRef === undefined) {
return (feature.alignmentStart >= start && feature.alignmentStart <= end);
}
// For mapped reads, check if they overlap the requested range
// Use > instead of >= for start boundary to match samtools behavior
return (feature.alignmentStart <= end &&
feature.alignmentStart + feature.lengthOnRef - 1 > start);
}, decodeOptions).then(records => {
downloadedBytes += slice.sliceBytes;
onProgress?.(downloadedBytes, totalBytes);
return records;
})));
let ret = sliceResults.flat();
if (viewAsPairs) {
const readNameCounts = {};
const seenUniqueIds = new Set();
for (const read of ret) {
const name = requireReadName(read);
readNameCounts[name] = (readNameCounts[name] ?? 0) + 1;
seenUniqueIds.add(read.uniqueId);
}
const unmatedReadNames = new Set(Object.keys(readNameCounts).filter(k => readNameCounts[k] === 1));
const matePromises = [];
for (const cramRecord of ret) {
const name = requireReadName(cramRecord);
if (unmatedReadNames.has(name) &&
cramRecord.mate &&
(cramRecord.mate.sequenceId === seqId || pairAcrossChr) &&
Math.abs(cramRecord.alignmentStart - cramRecord.mate.alignmentStart) <
maxInsertSize) {
matePromises.push(this.index.getEntriesForRange(cramRecord.mate.sequenceId, cramRecord.mate.alignmentStart, cramRecord.mate.alignmentStart + 1));
}
}
const mateBlocks = await Promise.all(matePromises);
// Dedupe slices by their identifying triple. Earlier this used
// Slice.toString(), but Slice is a plain interface — every value
// stringified to "[object Object]", silently collapsing all mate
// slices to one. slice.getRecords() caches internally, so we don't
// need our own cache layer here.
const uniqueMateSlices = new Map();
for (const s of mateBlocks.flat()) {
uniqueMateSlices.set(`${s.containerStart}:${s.sliceStart}:${s.sliceBytes}`, s);
}
const mateFeatPromises = [...uniqueMateSlices.values()].map(c => this.getRecordsInSlice(c, () => true).then(feats => {
const mateRecs = [];
for (const feature of feats) {
const name = requireReadName(feature);
if (unmatedReadNames.has(name) &&
!seenUniqueIds.has(feature.uniqueId)) {
mateRecs.push(feature);
}
}
return mateRecs;
}));
const newMateFeats = await Promise.all(mateFeatPromises);
ret = ret.concat(newMateFeats.flat());
}
return ret;
}
getRecordsInSlice({ containerStart, sliceStart, sliceBytes, }, filterFunction, decodeOptions) {
const container = this.cram.getContainerAtPosition(containerStart);
const slice = container.getSlice(sliceStart, sliceBytes);
return slice.getRecords(filterFunction, decodeOptions);
}
/**
*
* @param {number} seqId
* @returns {Promise} true if the CRAM file contains data for the given
* reference sequence numerical ID
*/
hasDataForReferenceSequence(seqId) {
return this.index.hasDataForReferenceSequence(seqId);
}
}
//# sourceMappingURL=indexedCramFile.js.map