UNPKG

@gmod/cram

Version:

read CRAM files with pure Javascript

261 lines (243 loc) 8.61 kB
import CramFile from './cramFile/index.ts' import { type DecodeOptions } from './cramFile/record.ts' import type { IndexOpts, Slice } from './craiIndex.ts' import type { SeqFetch } from './cramFile/file.ts' import type CramRecord from './cramFile/record.ts' import type { GenericFilehandle } from 'generic-filehandle2' export interface CramFileSource { cramFilehandle?: GenericFilehandle cramUrl?: string cramPath?: string } function requireReadName(record: CramRecord): string { const name = record.readName if (name === undefined) { throw new Error('readName undefined') } return name } export interface CramIndexLike { getEntriesForRange: ( seqId: number, start: number, end: number, opts?: IndexOpts, ) => Promise<Slice[]> hasDataForReferenceSequence: (seqId: number) => Promise<boolean> } export default class IndexedCramFile { public cram: CramFile public index: CramIndexLike /** * * @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: { index: CramIndexLike } & ( | { cram: CramFile } | ({ cram?: undefined seqFetch?: SeqFetch checkSequenceMD5?: boolean validateChecksums?: boolean cacheSize?: number } & CramFileSource) ), ) { 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: number, start: number, end: number, opts: { viewAsPairs?: boolean pairAcrossChr?: boolean maxInsertSize?: number /** * Called as the slices covering the query are fetched and decoded, with * cumulative processed bytes and the total to fetch. Reported at slice * granularity (one tick per slice, including instant ticks for cached * slices) since slice byte sizes are known up front from the index. Lets * callers render a determinate progress bar. */ onProgress?: (bytesDownloaded: number, totalBytes?: number) => void } & DecodeOptions = {}, ) { const viewAsPairs = opts.viewAsPairs ?? false const pairAcrossChr = opts.pairAcrossChr ?? false const maxInsertSize = opts.maxInsertSize ?? 200000 const onProgress = opts.onProgress const decodeOptions: 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: CramRecord[] = sliceResults.flat() if (viewAsPairs) { const readNameCounts: Record<string, number> = {} const seenUniqueIds = new Set<number>() 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<string, Slice>() 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, }: { containerStart: number; sliceStart: number; sliceBytes: number }, filterFunction: (r: CramRecord) => boolean, decodeOptions?: 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: number) { return this.index.hasDataForReferenceSequence(seqId) } }