UNPKG

@gmod/cram

Version:

read CRAM files with pure Javascript

164 lines 7.48 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const index_ts_1 = __importDefault(require("./cramFile/index.js")); function requireReadName(record) { const name = record.readName; if (name === undefined) { throw new Error('readName undefined'); } return name; } 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 index_ts_1.default({ 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); } } exports.default = IndexedCramFile; //# sourceMappingURL=indexedCramFile.js.map