diff --git a/.gitignore b/.gitignore index 26ed7f18..fbb4ddeb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ coverage # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release + +# Examples folder +examples # Dependency directory # Commenting this out is preferred by some people, see @@ -41,3 +44,12 @@ dist # Generated files src/lib *.pyc +.idea/.name +.idea/compiler.xml +.idea/misc.xml +.idea/modules.xml +.idea/pileup.js.iml +.idea/vcs.xml +.idea/workspace.xml +.idea/copyright/profiles_settings.xml +.idea/dictionaries/akmorrow.xml diff --git a/.vscode/.browse.VC.db b/.vscode/.browse.VC.db new file mode 100644 index 00000000..c3d7e904 Binary files /dev/null and b/.vscode/.browse.VC.db differ diff --git a/examples/json/reference.json b/examples/json/reference.json new file mode 100644 index 00000000..f36ca060 --- /dev/null +++ b/examples/json/reference.json @@ -0,0 +1,8 @@ +{ + "region": { + "contig": "chrM", + "start": "0", + "stop": "500" + }, + "sequence": "GTTAATGTAGCTTAATAACAAAGCAAAGCACTGAAAATGCTTAGATGGATAATTGTATCCCATAAACACAAAGGTTTGGTCCTGGCCTTATAATTAATTAGAGGTAAAATTACACATGCAAACCTCCATAGACCGGTGTAAAATCCCTTAAACATTTACTTAAAATTTAAGGAGAGGGTATCAAGCACATTAAAATAGCTTAAGACACCTTGCCTAGCCACACCCCCACGGGACTCAGCAGTGATAAATATTAAGCAATAAACGAAAGTTTGACTAAGTTATACCTCTTAGGGTTGGTAAATTTCGTGCCAGCCACCGCGGTCATACGATTAACCCAAACTAATTATCTTCGGCGTAAAACGTGTCAACTATAAATAAATAAATAGAATTAAAATCCAACTTATATGTGAAAATTCATTGTTAGGACCTAAACTCAATAACGAAAGTAATTCTAGTCATTTATAATACACGACAGCTAAGACCCAAACTGGGATTAGATA" +} \ No newline at end of file diff --git a/package.json b/package.json index 53c633ba..1b54505a 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "flow": "flow status", "flow-check": "flow check", "coverage": "./scripts/generate-coverage.sh", - "build": "./scripts/build.sh" + "build": "./scripts/build.sh", + "quick-build": "./scripts/quick-build.sh" }, "author": "Dan Vanderkam", "contributors": [ @@ -45,9 +46,6 @@ "url": "https://github.com/hammerlab/pileup.js/issues" }, "homepage": "https://github.com/hammerlab/pileup.js", - "prepush": [ - "lint" - ], "dependencies": { "backbone": "1.1.2", "d3": "^3.5.5", @@ -59,8 +57,7 @@ "react": "^0.14.0", "react-dom": "^0.14.0", "shallow-equals": "0.0.0", - "underscore": "^1.7.0", - "memory-cache": "0.1.6" + "underscore": "^1.7.0" }, "devDependencies": { "arraybuffer-slice": "^0.1.2", @@ -84,7 +81,6 @@ "number-to-locale-string": "^1.0.0", "parse-data-uri": "^0.2.0", "phantomjs": "1.9.17", - "prepush-hook": "^0.1.0", "react-addons-test-utils": "^0.14.0", "sinon": "^1.12.2", "smash": "0.0.14", diff --git a/scripts/test.sh b/scripts/test.sh index 6dd937d2..9914fba4 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -18,4 +18,4 @@ trap finish EXIT sleep 1 # Start the tests -mocha-phantomjs http://localhost:8081/src/test/runner.html "$@" +mocha-phantomjs http://localhost:8081/src/test/runner.html diff --git a/src/main/GA4GHAlignment.js b/src/main/GA4GHAlignment.js index abdc0adf..10ddd18f 100644 --- a/src/main/GA4GHAlignment.js +++ b/src/main/GA4GHAlignment.js @@ -39,11 +39,12 @@ class GA4GHAlignment /* implements Alignment */ { // https://github.com/ga4gh/schemas/blob/v0.5.1/src/main/resources/avro/reads.avdl constructor(alignment: Object) { this.alignment = alignment; - this.pos = alignment.alignment.position.position; - this.ref = alignment.alignment.position.referenceName; - this.name = alignment.fragmentName; + // console.log(alignment) + this.pos = alignment.alignment.position.position; + this.ref = alignment.alignment.position.referenceName; + this.name = alignment.fragmentName; - this.cigarOps = alignment.alignment.cigar.map( + this.cigarOps = alignment.alignment.cigar.map( ({operation, operationLength: length}) => ({ op: OP_MAP[operation], length })); this._interval = new ContigInterval(this.ref, this.pos, diff --git a/src/main/RemoteRequest.js b/src/main/RemoteRequest.js index 578c4c2d..f362c670 100644 --- a/src/main/RemoteRequest.js +++ b/src/main/RemoteRequest.js @@ -9,71 +9,82 @@ import Q from 'q'; import ContigInterval from './ContigInterval'; +var BASE_PAIRS_PER_FETCH = 1000; +var MONSTER_REQUEST = 5000000; + class RemoteRequest { url: string; - cache: Object; + basePairsPerFetch: number; numNetworkRequests: number; // track this for debugging/testing - constructor(url: string) { - this.cache = require('memory-cache'); + constructor(url: string, basePairsPerFetch?: number) { this.url = url; + if (!basePairsPerFetch) + this.basePairsPerFetch = BASE_PAIRS_PER_FETCH; + else + this.basePairsPerFetch = basePairsPerFetch; this.numNetworkRequests = 0; } - get(contig: string, start: number, stop: number): Q.Promise { - var length = stop - start; + expandRange(range: ContigInterval): ContigInterval { + var roundDown = x => x - x % this.basePairsPerFetch; + var newStart = Math.max(1, roundDown(range.start())), + newStop = roundDown(range.stop() + this.basePairsPerFetch - 1); + + return new ContigInterval(range.contig, newStart, newStop); + } + + getFeaturesInRange(range: ContigInterval, modifier: string = ""): Q.Promise { + var expandedRange = this.expandRange(range); + return this.get(expandedRange, modifier); + } + + get(range: ContigInterval, modifier: string = ""): Q.Promise { + + var length = range.stop() - range.start(); if (length <= 0) { return Q.reject(`Requested <0 interval (${length}) from ${this.url}`); + } else if (length > MONSTER_REQUEST) { + throw `Monster request: Won't fetch ${length} sized ranges from ${this.url}`; } - - // First check the cache. - var contigInterval = new ContigInterval(contig, start, stop); - var buf = this.cache.get(contigInterval); - if (buf) { - return Q.when(buf); - } - - // Need to fetch from the network. - return this.getFromNetwork(contig, start, stop); + // get endpoint + var endpoint = this.getEndpointFromContig(range.contig, range.start(), range.stop(), modifier); + // Fetch from the network + return this.getFromNetwork(endpoint); } /** * Request must be of form "url/contig?start=start&end=stop" */ - getFromNetwork(contig: string, start: number, stop: number): Q.Promise { - var length = stop - start; - if (length > 5000000) { - throw `Monster request: Won't fetch ${length} sized ranges from ${this.url}`; - } + getFromNetwork(endpoint: string): Q.Promise { var xhr = new XMLHttpRequest(); - var endpoint = this.getEndpointFromContig(contig, start, stop); xhr.open('GET', endpoint); xhr.responseType = 'json'; xhr.setRequestHeader('Content-Type', 'application/json'); - return this.promiseXHR(xhr).then(json => { - // extract response from promise - var buffer = json[0]; - var contigInterval = new ContigInterval(contig, start, stop); - this.cache.put(contigInterval, buffer); - return buffer; + return this.promiseXHR(xhr).then(e => { + // send back response and status + return e[0]; }); } - getEndpointFromContig(contig: string, start: number, stop: number): string { - return `${this.url}/${contig}?start=${start}&end=${stop}`; + getEndpointFromContig(contig: string, start: number, stop: number, modifier: string = ""): string { + if (modifier.length < 1) + return `${this.url}/${contig}?start=${start}&end=${stop}`; + else + return `${this.url}/${contig}?start=${start}&end=${stop}&${modifier}`; } // Wrapper to convert XHRs to Promises. // The promised values are the response (e.g. an ArrayBuffer) and the Event. - promiseXHR(xhr: XMLHttpRequest): Q.Promise<[any, Event]> { + promiseXHR(xhr: XMLHttpRequest): Q.Promise<[any]> { var url = this.url; var deferred = Q.defer(); xhr.addEventListener('load', function(e) { if (this.status >= 400) { deferred.reject(`Request for ${url} failed with status: ${this.status} ${this.statusText}`); } else { - deferred.resolve([this.response, e]); + deferred.resolve([this]); } }); xhr.addEventListener('error', function(e) { @@ -85,4 +96,9 @@ class RemoteRequest { } } -module.exports = RemoteRequest; +module.exports = { + RemoteRequest, + MONSTER_REQUEST: MONSTER_REQUEST +}; + +//module.exports = RemoteRequest; diff --git a/src/main/ResolutionCache.js b/src/main/ResolutionCache.js new file mode 100644 index 00000000..019bc22e --- /dev/null +++ b/src/main/ResolutionCache.js @@ -0,0 +1,198 @@ +/** + * Cache for any Data Sources that rely on resolution. For example, + * this is used by coverage to keep track of the difference resolutions + * that have been fetched already. + * + * @flow + */ +'use strict'; + +/* exported Interval, ContigInterval */ +import _ from 'underscore'; +import Interval from './Interval'; +import ContigInterval from './ContigInterval'; + + +class ResolutionCache { + coveredRanges: ResolutionCacheKey[]; + cache: {[resolution: number]: {[key: string]: T}}; + // used to filter out elements in the cache based on resolution. + filterFunction: Function; // should take form (range: ContigInterval, T) => boolean; + keyFunction: Function; // should take form (d: T) => string; + + constructor(filterFunction: Function, keyFunction: Function) { + this.coveredRanges = []; + this.cache = {}; + this.filterFunction = filterFunction; + this.keyFunction = keyFunction; + } + + // gets data from cache at the Resolution defined by the interval + get(range: ContigInterval, resolution: ?number): T[] { + if (!range) return []; + var res = {}; + if (!resolution) { + res = _.filter(this.cache[1], d => this.filterFunction(range, d)); + } else { + res = _.filter(this.cache[resolution], d => this.filterFunction(range, d)); + } + + return res; + } + + /** + * Find the disjoint subintervals not covered by any interval in the list with the same resolution. + * + * If comp = interval.complementIntervals(ranges), then this guarantees that: + * - comp union ranges = interval + * - a int b = 0 forall a \in comp, b in ranges + * + * (The input ranges need not be disjoint.) + */ + complementInterval(range: ContigInterval, resolution: ?number): ContigInterval[] { + if (!resolution) { + resolution = ResolutionCache.getResolution(range.interval); + } + + // filter ranges by correct resolution + var resolutionIntervals = _.filter(this.coveredRanges, r => r.resolution == resolution) + .map(r => r.contigInterval); + + return range.complementIntervals(resolutionIntervals); + + } + + // puts new ranges into list of ranges covered by cache + coverRange(range: ContigInterval, resolution: ?number) { + if (!resolution) { + resolution = ResolutionCache.getResolution(range.interval); + } + var resolvedRange = new ResolutionCacheKey(range, resolution); + this.coveredRanges.push(resolvedRange); + // coalesce new contigIntervals + this.coveredRanges = ResolutionCacheKey.coalesce(this.coveredRanges); + } + + // puts data in cache + put(value: T, resolution: ?number) { + if (!resolution) { + resolution = 1; + } + var key = this.keyFunction(value); + + // initialize cache resolution, if not already initialized + if (!this.cache[resolution]) this.cache[resolution] = {}; + + if (!this.cache[resolution][key]) { + this.cache[resolution][key] = value; + } + } + + // checks weather cache contains data for the + // specified interval and its corresponding resolution + coversRange(range: ContigInterval, + resolution: ?number): boolean { + if (!resolution) { + resolution = ResolutionCache.getResolution(range.interval); + } + + // filter ranges by correct resolution + var resolutionRanges = _.filter(this.coveredRanges, r => r.resolution == resolution); + if (range.isCoveredBy(resolutionRanges.map(r => r.contigInterval))) { + return true; + } else return false; + } + + // clears out all content in cache + clear() { + this.coveredRanges = []; + this.cache = {}; + } + + /** + * Gets the Base Pairs per bin given a specified interval + * This is used to bin coverage when viewing large regions + * + * Values were chosen based on a 1000 pixel screen (a conservative estimate): + * - For regions < 10,000 base pairs, no binning is performed (return 1) + * - For regions >= 10,000 and < 100,000, bin 10 bp into 1 (return 10) + * - For regions >= 100,000 and < 1,000,000, bin 100 bp into 1 (return 100) + * - For regions >= 1,000,000, bin 1000 bp into 1 (return 1000) + */ + static getResolution(range: Interval): number { + // subtract one because length() adds one + var rangeLength = range.length() - 1; + if (rangeLength < 10000) + return 1; + else if (rangeLength >= 10000 && rangeLength < 100000 ) + return 10; + else if (rangeLength >= 100000 && rangeLength < 1000000 ) + return 100; + else + return 1000; + } +} + +/** + * Class holds a ContigInterval and resolution that designates whether + * a contig interval represents data at a certain resolution. The + * parameters for choosing a resolution based on interval length are set + * in getResolution. + * + */ +class ResolutionCacheKey { + contigInterval: ContigInterval; + resolution: number; + + constructor(contigInterval: ContigInterval, resolution: number) { + this.contigInterval = contigInterval; + this.resolution = resolution; + } + + clone(): ResolutionCacheKey { + return new ResolutionCacheKey(this.contigInterval.clone(), this.resolution); + } + + // Sort an array of intervals & coalesce adjacent/overlapping ranges. + // NB: this may re-order the intervals parameter + static coalesce(intervals: ResolutionCacheKey[]): ResolutionCacheKey[] { + intervals.sort(ResolutionCacheKey.compare); + + var rs = []; + intervals.forEach(r => { + if (rs.length === 0) { + rs.push(r); + return; + } + + var lastR: ResolutionCacheKey = rs[rs.length - 1]; + if ((r.contigInterval.intersects(lastR.contigInterval) || + r.contigInterval.isAdjacentTo(lastR.contigInterval)) && + r.resolution == lastR.resolution) { + lastR = rs[rs.length - 1] = lastR.clone(); + lastR.contigInterval.interval.stop = + Math.max(r.contigInterval.interval.stop, lastR.contigInterval.interval.stop); + } else { + rs.push(r); + } + }); + + return rs; + } + + // Comparator for use with Array.prototype.sort + static compare(a: ResolutionCacheKey, b: ResolutionCacheKey): number { + if (a.contigInterval.contig > b.contigInterval.contig) { + return -1; + } else if (a.contigInterval.contig < b.contigInterval.contig) { + return +1; + } else { + return a.contigInterval.start() - b.contigInterval.start(); + } + } + +} + +module.exports = { + ResolutionCache +}; diff --git a/src/main/Root.js b/src/main/Root.js index 0e33904c..d6811179 100644 --- a/src/main/Root.js +++ b/src/main/Root.js @@ -7,9 +7,11 @@ import type {TwoBitSource} from './sources/TwoBitDataSource'; import type {VisualizedTrack, VizWithOptions} from './types'; +import _ from 'underscore'; import React from 'react'; import Controls from './Controls'; import Menu from './Menu'; +import type {SequenceRecord} from './data/Sequence'; import VisualizationWrapper from './VisualizationWrapper'; type Props = { @@ -21,7 +23,8 @@ type Props = { class Root extends React.Component { props: Props; state: { - contigList: string[]; + contigList: SequenceRecord[]; + contigNames: string[]; range: ?GenomeRange; settingsMenuKey: ?string; }; @@ -30,6 +33,7 @@ class Root extends React.Component { super(props); this.state = { contigList: this.props.referenceSource.contigList(), + contigNames: _.map(this.props.referenceSource.contigList(), contig => contig.name), range: null, settingsMenuKey: null }; @@ -54,6 +58,13 @@ class Root extends React.Component { if (newRange.start < 0) { newRange.start = 0; } + var record = _.find(this.state.contigList, contig => contig.name == newRange.contig); + if (record) { + if (newRange.stop > record.length) { + newRange.stop = record.length; + } + } + this.props.referenceSource.normalizeRange(newRange).then(range => { this.setState({range: range}); @@ -153,7 +164,7 @@ class Root extends React.Component {  
-
diff --git a/src/main/data/Sequence.js b/src/main/data/Sequence.js new file mode 100644 index 00000000..c8fd5298 --- /dev/null +++ b/src/main/data/Sequence.js @@ -0,0 +1,55 @@ +/** + * This module defines a parser for the 2bit file format. + * See http://genome.ucsc.edu/FAQ/FAQformat.html#format7 + * @flow + */ +'use strict'; + +/* exported Q, ContigInterval, RemoteRequest */ +import Q from 'q'; +import ContigInterval from '../ContigInterval'; +import {RemoteRequest} from '../RemoteRequest'; + +export type SequenceRecord = { + name: string; + length: number; +} + +class Sequence { + remoteRequest: RemoteRequest; + contigList: SequenceRecord[]; + + constructor(remoteRequest: RemoteRequest, contigList: SequenceRecord[]) { + this.remoteRequest = remoteRequest; + this.contigList = contigList; + } + + // Returns a list of contig names. + getContigNames(): string[] { + return this.contigList.map(seq => seq.name); + } + + // Returns a list of contig names. + getContigs(): SequenceRecord[] { + return this.contigList; + } + + /** + * Returns the base pairs for contig:start-stop. + * The range is inclusive and zero-based. + * Returns empty string if no data is available on this range. + */ + getFeaturesInRange(range: ContigInterval): Q.Promise { + var start = range.start(); + var stop = range.stop(); + if (start > stop) { + throw `Requested a range with start > stop (${start}, ${stop})`; + } + return this.remoteRequest.get(range).then(e => { + return e.response; + }); + } + +} + +module.exports = Sequence; diff --git a/src/main/data/VirtualOffset.js b/src/main/data/VirtualOffset.js index 287c05f2..5d3dcc78 100644 --- a/src/main/data/VirtualOffset.js +++ b/src/main/data/VirtualOffset.js @@ -6,8 +6,7 @@ * JavaScript. * @flow */ - -"use strict"; +'use strict'; class VirtualOffset { coffset: number; diff --git a/src/main/data/vcf.js b/src/main/data/vcf.js index 558eb623..00fd2b2d 100644 --- a/src/main/data/vcf.js +++ b/src/main/data/vcf.js @@ -17,6 +17,13 @@ export type Variant = { ref: string; alt: string; vcfLine: string; + end: number; +} + +// holds variant and genotype sample ids +export type VariantContext = { + variant: Variant, + sampleIds: string[] } // This is a minimally-parsed line for facilitating binary search. @@ -41,13 +48,17 @@ function extractLocusLine(vcfLine: string): LocusLine { function extractVariant(vcfLine: string): Variant { var parts = vcfLine.split('\t'); - + var end = Number(parts[1]) + parts[3].length; + if (5 >= parts.length) { + end = parts[5]; + } return { contig: parts[0], position: Number(parts[1]), ref: parts[3], alt: parts[4], - vcfLine + vcfLine: vcfLine, + end: Number(end) }; } diff --git a/src/main/pileup.js b/src/main/pileup.js index 966c819d..9e170b29 100644 --- a/src/main/pileup.js +++ b/src/main/pileup.js @@ -12,20 +12,27 @@ import ReactDOM from 'react-dom'; // Data sources import TwoBitDataSource from './sources/TwoBitDataSource'; +import ReferenceDataSource from './sources/ReferenceDataSource'; import BigBedDataSource from './sources/BigBedDataSource'; import VcfDataSource from './sources/VcfDataSource'; +import VariantDataSource from './sources/VariantDataSource'; import BamDataSource from './sources/BamDataSource'; import GA4GHDataSource from './sources/GA4GHDataSource'; +import CoverageDataSource from './sources/CoverageDataSource'; import EmptySource from './sources/EmptySource'; +import FeatureDataSource from './sources/FeatureDataSource'; // Visualizations +import PileupCoverageTrack from './viz/PileupCoverageTrack'; import CoverageTrack from './viz/CoverageTrack'; import GenomeTrack from './viz/GenomeTrack'; import GeneTrack from './viz/GeneTrack'; +import FeatureTrack from './viz/FeatureTrack'; import LocationTrack from './viz/LocationTrack'; import PileupTrack from './viz/PileupTrack'; import ScaleTrack from './viz/ScaleTrack'; import VariantTrack from './viz/VariantTrack'; +import GenotypeTrack from './viz/GenotypeTrack'; import Root from './Root'; type GenomeRange = { @@ -123,17 +130,24 @@ var pileup = { bam: BamDataSource.create, ga4gh: GA4GHDataSource.create, vcf: VcfDataSource.create, + variants: VariantDataSource.create, + features: FeatureDataSource.create, twoBit: TwoBitDataSource.create, + reference: ReferenceDataSource.create, bigBed: BigBedDataSource.create, + coverage: CoverageDataSource.create, empty: EmptySource.create }, viz: { + pileupcoverage: makeVizObject(PileupCoverageTrack), coverage: makeVizObject(CoverageTrack), genome: makeVizObject(GenomeTrack), genes: makeVizObject(GeneTrack), + features: makeVizObject(FeatureTrack), location: makeVizObject(LocationTrack), scale: makeVizObject(ScaleTrack), variants: makeVizObject(VariantTrack), + genotypes: makeVizObject(GenotypeTrack), pileup: makeVizObject(PileupTrack) }, version: '0.6.8' diff --git a/src/main/sources/CoverageDataSource.js b/src/main/sources/CoverageDataSource.js new file mode 100644 index 00000000..ab796239 --- /dev/null +++ b/src/main/sources/CoverageDataSource.js @@ -0,0 +1,144 @@ +/** + * Remote Endpoint for coverage. + * + * CoverageDataSource is purely for data parsing and fetching. + * Coverage for CoverageDataSource can be calculated from any source, + * including, but not limited to, Alignment Records, + * variants or features. + * + * @flow + */ +'use strict'; + +import Q from 'q'; +import _ from 'underscore'; +import {Events} from 'backbone'; +import {ResolutionCache} from '../ResolutionCache'; +import ContigInterval from '../ContigInterval'; +import {RemoteRequest} from '../RemoteRequest'; + +export type CoverageDataSource = { + maxCoverage: (range: ContigInterval) => number; + rangeChanged: (newRange: GenomeRange) => void; + getCoverageInRange: (range: ContigInterval) => PositionCount[]; + on: (event: string, handler: Function) => void; + off: (event: string) => void; + trigger: (event: string, ...args:any) => void; +}; + +var BASE_PAIRS_PER_FETCH = 5000; + +export type PositionCount = { + contig: string; + start: number; + end: number; + count: number; +} + +function keyFunction(p: PositionCount): string { + return `${p.contig}:${p.start}-${p.end}`; +} + +function filterFunction(range: ContigInterval, p: PositionCount): boolean { + return range.chrContainsLocus(p.contig, p.start); +} + +function createFromCoverageUrl(remoteSource: RemoteRequest): CoverageDataSource { + var cache: ResolutionCache = + new ResolutionCache(filterFunction, keyFunction); + + function notifyFailure(message: string) { + o.trigger('networkfailure', message); + o.trigger('networkdone'); + console.warn(message); + } + + function maxCoverage(range: ContigInterval, resolution: ?number): number { + var positions: number[] = cache.get(range, resolution).map(r => r.count); + var maxCoverage = Math.max.apply(Math, positions); + return maxCoverage; + } + + function fetch(range: GenomeRange) { + var startTimeMilliseconds = new Date().getTime(); + var interval = new ContigInterval(range.contig, range.start, range.stop); + + // Check if this interval is already in the cache. + if (cache.coversRange(interval)) { + console.info(`Time to get coverage from cache:", ${new Date().getTime() - startTimeMilliseconds}`); + return Q.when(); + } + + // modify endpoint to calculate coverage using binning + var resolution = ResolutionCache.getResolution(interval.interval); + var endpointModifier = `binning=${resolution}`; + + // get all smaller intervals not yet covered in cache + var newRanges = cache.complementInterval(interval, resolution); + + // Cover the range immediately to prevent duplicate fetches. + cache.coverRange(interval); + o.trigger('networkprogress', newRanges.length); + return Q.all(newRanges.map(range => + remoteSource.getFeaturesInRange(range, endpointModifier).then(json => { + var response = json.response; + if (json.status >= 400) { + notifyFailure(json.status + ' ' + json.statusText + ' ' + JSON.stringify(response)); + } else { + if (response.errorCode) { + notifyFailure('Error from CoverageDataSource: ' + JSON.stringify(response)); + } else { + // add new data to cache + response.forEach(p => cache.put({ + "contig": range.contig, + "start": p.start, + "end": p.end, + "count": p.count + }, resolution)); + o.trigger('newdata', interval); + } + } + console.info(`Fetched coverage from server:", ${new Date().getTime() - startTimeMilliseconds}`); + o.trigger('networkdone'); + }))); + } + + function getCoverageInRange(range: ContigInterval, + resolution: ?number): PositionCount[] { + if (!range) return []; + var data = cache.get(range, resolution); + var sorted = data.sort((a, b) => a.start - b.start); + return sorted; + } + + var o = { + maxCoverage, + rangeChanged: function(newRange: GenomeRange) { + fetch(newRange).done(); + }, + getCoverageInRange, + + // These are here to make Flow happy. + on: () => {}, + off: () => {}, + trigger: () => {} + }; + _.extend(o, Events); + + return o; +} + +function create(data: {url?:string}): CoverageDataSource { + if (!data.url) { + throw new Error(`Missing URL from track: ${JSON.stringify(data)}`); + } + + var endpoint = new RemoteRequest(data.url, BASE_PAIRS_PER_FETCH); + return createFromCoverageUrl(endpoint); +} + + +module.exports = { + create, + createFromCoverageUrl +}; diff --git a/src/main/sources/FeatureConvertDataSource.js b/src/main/sources/FeatureConvertDataSource.js new file mode 100644 index 00000000..66e53b8d --- /dev/null +++ b/src/main/sources/FeatureConvertDataSource.js @@ -0,0 +1,25 @@ +// /* @flow */ +// 'use strict'; + +// import type {Strand} from '../Alignment'; + +// import _ from 'underscore'; +// import Q from 'q'; +// import {Events} from 'backbone'; + +// import ContigInterval from '../ContigInterval'; +// import Interval from '../Interval'; +// import BigBed from '../data/BigBed'; +// import type {Gene, BigBedSource} from './BigBedDataSource'; + +// export type FeatureConvertDataSource = { +// featureId: string; +// featureType: string; +// start: number; +// end: number; +// range: ContigInterval +// } + +// function getFeaturesInRange(f): BigBedSource { + +// } diff --git a/src/main/sources/FeatureDataSource.js b/src/main/sources/FeatureDataSource.js new file mode 100644 index 00000000..dd720add --- /dev/null +++ b/src/main/sources/FeatureDataSource.js @@ -0,0 +1,141 @@ +/** + * The "glue" between TwoBit.js and GenomeTrack.js. + * + * GenomeTrack is pure view code -- it renders data which is already in-memory + * in the browser. + * + * TwoBit is purely for data parsing and fetching. It only knows how to return + * promises for various genome features. + * + * This code acts as a bridge between the two. It maintains a local version of + * the data, fetching remote data and informing the view when it becomes + * available. + * + * @flow + */ +'use strict'; + +import Q from 'q'; +import _ from 'underscore'; +import {Events} from 'backbone'; +import {ResolutionCache} from '../ResolutionCache'; + +import ContigInterval from '../ContigInterval'; +import {RemoteRequest} from '../RemoteRequest'; + +export type Feature = { + id: string; + featureType: string; + contig: string; + start: number; + stop: number; + score: number; +} + +// Flow type for export. +export type FeatureDataSource = { + rangeChanged: (newRange: GenomeRange) => void; + getFeaturesInRange: (range: ContigInterval) => Feature[]; + on: (event: string, handler: Function) => void; + off: (event: string) => void; + trigger: (event: string, ...args:any) => void; +} + + +// Requests for 2bit ranges are expanded to begin & end at multiples of this +// constant. Doing this means that panning typically won't require +// additional network requests. +var BASE_PAIRS_PER_FETCH = 10000; + +function expandRange(range: ContigInterval): ContigInterval { + var roundDown = x => x - x % BASE_PAIRS_PER_FETCH; + var newStart = Math.max(0, roundDown(range.start())), + newStop = roundDown(range.stop() + BASE_PAIRS_PER_FETCH - 1); + + return new ContigInterval(range.contig, newStart, newStop); +} + +function keyFunction(f: Feature): string { + return `${f.contig}:${f.start}`; +} + +function filterFunction(range: ContigInterval, f: Feature): boolean { + return range.chrIntersects(new ContigInterval(f.contig, f.start, f.stop)); +} + + +function createFromFeatureUrl(remoteSource: RemoteRequest): FeatureDataSource { + var cache: ResolutionCache = + new ResolutionCache(filterFunction, keyFunction); + + function fetch(range: GenomeRange) { + var startTimeMilliseconds = new Date().getTime(); + var interval = new ContigInterval(range.contig, range.start, range.stop); + + // Check if this interval is already in the cache. + if (cache.coversRange(interval)) { + console.info(`Time to get features from cache:", ${new Date().getTime() - startTimeMilliseconds}`); + return Q.when(); + } + + // modify endpoint to calculate coverage using binning + var resolution = ResolutionCache.getResolution(interval.interval); + var endpointModifier = `binning=${resolution}`; + + interval = expandRange(interval); + var newRanges = cache.complementInterval(interval, resolution); + + // "Cover" the range immediately to prevent duplicate fetches. + // Because interval is expanded, make sure to use original resolution + cache.coverRange(interval, resolution); + + o.trigger('networkprogress', newRanges.length); + return Q.all(newRanges.map(range => + remoteSource.getFeaturesInRange(range, endpointModifier) + .then(e => { + var features = e.response; + if (features !== null) { + features.forEach(feature => cache.put(feature, resolution)); + } + console.info(`Fetched features from server:", ${new Date().getTime() - startTimeMilliseconds}`); + o.trigger('networkdone'); + o.trigger('newdata', range); + }))); + } + + function getFeaturesInRange(range: ContigInterval, resolution: ?number): Feature[] { + if (!range) return []; // XXX why would this happen? + var data = cache.get(range, resolution); + var sorted = data.sort((a, b) => a.start - b.start); + return sorted; + } + + var o = { + rangeChanged: function(newRange: GenomeRange) { + fetch(newRange).done(); + }, + getFeaturesInRange, + + // These are here to make Flow happy. + on: () => {}, + off: () => {}, + trigger: () => {} + }; + _.extend(o, Events); // Make this an event emitter + + return o; +} + +function create(data: {url?:string}): FeatureDataSource { + if (!data.url) { + throw new Error(`Missing URL from track: ${JSON.stringify(data)}`); + } + var endpoint = new RemoteRequest(data.url); + return createFromFeatureUrl(endpoint); +} + + +module.exports = { + create, + createFromFeatureUrl +}; diff --git a/src/main/sources/GA4GHDataSource.js b/src/main/sources/GA4GHDataSource.js index 31d76e28..a815c681 100644 --- a/src/main/sources/GA4GHDataSource.js +++ b/src/main/sources/GA4GHDataSource.js @@ -9,11 +9,13 @@ import type {Alignment, AlignmentDataSource} from '../Alignment'; import _ from 'underscore'; import {Events} from 'backbone'; +import Q from 'q'; import ContigInterval from '../ContigInterval'; import GA4GHAlignment from '../GA4GHAlignment'; var ALIGNMENTS_PER_REQUEST = 200; // TODO: explain this choice. +var MAX_BASE_PAIRS_TO_FETCH = 40000; // Genome ranges are rounded to multiples of this for fetching. @@ -21,7 +23,7 @@ var ALIGNMENTS_PER_REQUEST = 200; // TODO: explain this choice. // TODO: tune this value -- setting it close to the read length will result in // lots of reads being fetched twice, but setting it too large will result in // bulkier requests. -var BASE_PAIRS_PER_FETCH = 100; +var BASE_PAIRS_PER_FETCH = 1000; function expandRange(range: ContigInterval) { var roundDown = x => x - x % BASE_PAIRS_PER_FETCH; @@ -40,11 +42,7 @@ type GA4GHSpec = { }; function create(spec: GA4GHSpec): AlignmentDataSource { - if (spec.endpoint.slice(-6) != 'v0.5.1') { - throw new Error('Only v0.5.1 of the GA4GH API is supported by pileup.js'); - } - - var url = spec.endpoint + '/reads/search'; + var url = spec.endpoint; var reads: {[key:string]: Alignment} = {}; @@ -53,12 +51,16 @@ function create(spec: GA4GHSpec): AlignmentDataSource { function addReadsFromResponse(response: Object) { response.alignments.forEach(alignment => { - // optimization: don't bother constructing a GA4GHAlignment unless it's new. - var key = GA4GHAlignment.keyFromGA4GHResponse(alignment); - if (key in reads) return; - - var ga4ghAlignment = new GA4GHAlignment(alignment); - reads[key] = ga4ghAlignment; + try{ + // optimization: don't bother constructing a GA4GHAlignment unless it's new. + var key = GA4GHAlignment.keyFromGA4GHResponse(alignment); + if (key in reads) return; + + var ga4ghAlignment = new GA4GHAlignment(alignment); + reads[key] = ga4ghAlignment; + } catch(TypeError){ + console.log("Error in Matepair Data Source."); + } }); } @@ -70,17 +72,22 @@ function create(spec: GA4GHSpec): AlignmentDataSource { interval = expandRange(interval); - // select only intervals not yet loaded into coveredRangesß - var intervals = interval.complementIntervals(coveredRanges); - - // We "cover" the interval immediately (before the reads have arrived) to - // prevent duplicate network requests. - coveredRanges.push(interval); - coveredRanges = ContigInterval.coalesce(coveredRanges); - - intervals.forEach(i => { - fetchAlignmentsForInterval(i, null, 1 /* first request */); - }); + // if range is too large, return immediately + if (interval.length() > MAX_BASE_PAIRS_TO_FETCH) { + return; + } else { + // select only intervals not yet loaded into coveredRangesß + var intervals = interval.complementIntervals(coveredRanges); + + // We "cover" the interval immediately (before the reads have arrived) to + // prevent duplicate network requests. + coveredRanges.push(interval); + coveredRanges = ContigInterval.coalesce(coveredRanges); + + intervals.forEach(i => { + fetchAlignmentsForInterval(i, null, 1 /* first request */); + }); + } } function notifyFailure(message: string) { @@ -92,14 +99,24 @@ function create(spec: GA4GHSpec): AlignmentDataSource { function fetchAlignmentsForInterval(range: ContigInterval, pageToken: ?string, numRequests: number) { + var startTimeMilliseconds = new Date().getTime(); + + var span = range.length(); + if (span > MAX_BASE_PAIRS_TO_FETCH) { + console.info(`Time to get alignments from cache:", ${new Date().getTime() - startTimeMilliseconds}`); + return Q.when(); // empty promise + } var xhr = new XMLHttpRequest(); - xhr.open('POST', url); + + var endpoint = `${url}/${spec.readGroupId}/${range.contig}?start=${range.start()}&end=${range.stop()}`; + + xhr.open('GET', endpoint); xhr.responseType = 'json'; xhr.setRequestHeader('Content-Type', 'application/json'); xhr.addEventListener('load', function(e) { var response = this.response; - if (this.status >= 400) { + if (this.status != 200) { notifyFailure(this.status + ' ' + this.statusText + ' ' + JSON.stringify(response)); } else { if (response.errorCode) { @@ -110,6 +127,7 @@ function create(spec: GA4GHSpec): AlignmentDataSource { if (response.nextPageToken) { fetchAlignmentsForInterval(range, response.nextPageToken, numRequests + 1); } else { + console.info(`Fetched alignments from server:", ${new Date().getTime() - startTimeMilliseconds}`); o.trigger('networkdone'); } } @@ -155,5 +173,6 @@ function create(spec: GA4GHSpec): AlignmentDataSource { } module.exports = { - create + create, + MAX_BASE_PAIRS_TO_FETCH: MAX_BASE_PAIRS_TO_FETCH }; diff --git a/src/main/sources/ReferenceDataSource.js b/src/main/sources/ReferenceDataSource.js new file mode 100644 index 00000000..6cc2ccef --- /dev/null +++ b/src/main/sources/ReferenceDataSource.js @@ -0,0 +1,184 @@ +/** + * The "glue" between Sequence.js and GenomeTrack.js. + * + * GenomeTrack is pure view code -- it renders data which is already in-memory + * in the browser. + * + *Sequence is purely for data parsing and fetching. It only knows how to return + * promises for various genome features. + * + * This code acts as a bridge between the two. It maintains a local version of + * the data, fetching remote data and informing the view when it becomes + * available. + * + * @flow + */ +'use strict'; + +import Q from 'q'; +import _ from 'underscore'; +import {Events} from 'backbone'; + +import ContigInterval from '../ContigInterval'; +import Sequence from '../data/Sequence'; +import type {SequenceRecord} from '../data/Sequence'; +import SequenceStore from '../SequenceStore'; +import type {TwoBitSource} from './TwoBitDataSource'; +import {RemoteRequest} from '../RemoteRequest'; +import utils from '../utils'; + + +// Requests for 2bit ranges are expanded to begin & end at multiples of this +// constant. Doing this means that panning typically won't require +// additional network requests. +var BASE_PAIRS_PER_FETCH = 10000; + +var MAX_BASE_PAIRS_TO_FETCH = 100000; + +// Expand range to begin and end on multiples of BASE_PAIRS_PER_FETCH. +function expandRange(range) { + var roundDown = x => x - x % BASE_PAIRS_PER_FETCH; + var newStart = Math.max(0, roundDown(range.start())), + newStop = roundDown(range.stop() + BASE_PAIRS_PER_FETCH - 1); + + return new ContigInterval(range.contig, newStart, newStop); +} + +var createFromReferenceUrl = function(remoteSource: Sequence): TwoBitSource { + // Local cache of genomic data. + var contigList = remoteSource.getContigs(); + var store = new SequenceStore(); + + // Ranges for which we have complete information -- no need to hit network. + var coveredRanges = ([]: ContigInterval[]); + + function fetch(range: ContigInterval) { + var startTimeMilliseconds = new Date().getTime(); + var span = range.length(); + if (span > MAX_BASE_PAIRS_TO_FETCH) { + console.info(`Time to get reference from cache:", ${new Date().getTime() - startTimeMilliseconds}`); + return Q.when(); // empty promise + } + + // TODO remote Source + remoteSource.getFeaturesInRange(range) + .then(letters => { + if (!letters) return; + if (letters.length < range.length()) { + // Probably at EOF + range = new ContigInterval(range.contig, + range.start(), + range.start() + letters.length - 1); + } + store.setRange(range, letters); + }).then(() => { + console.info(`Fetched reference from server:", ${new Date().getTime() - startTimeMilliseconds}`); + o.trigger('newdata', range); + }).done(); + } + + // This either adds or removes a 'chr' as needed. + function normalizeRange(range: GenomeRange): Q.Promise { + var contigIdx = _.map(contigList, contig => contig.name).indexOf(range.contig); + if (contigIdx >= 0) { + return Q.Promise.resolve(range); + } + var altContig = utils.altContigName(range.contig); + var altIdx = _.map(contigList, contig => contig.name).indexOf(altContig); + if (altIdx >= 0) { + return Q.Promise.resolve({ + contig: altContig, + start: range.start, + stop: range.stop + }); + } + return Q.Promise.resolve(range); // cast as promise to conform to TwoBitDataSource + } + + // Returns a {"chr12:123" -> "[ATCG]"} mapping for the range. + function getRange(range: GenomeRange) { + return store.getAsObjects(ContigInterval.fromGenomeRange(range)); + } + + // Returns a string of base pairs for this range. + function getRangeAsString(range: GenomeRange): string { + if (!range) return ''; + return store.getAsString(ContigInterval.fromGenomeRange(range)); + } + + var o = { + // The range here is 0-based, inclusive + rangeChanged: function(newRange: GenomeRange) { + normalizeRange(newRange).then(r => { + var range = new ContigInterval(r.contig, r.start, r.stop); + // Check if this interval is already in the cache. + if (range.isCoveredBy(coveredRanges)) { + return; + } + + range = expandRange(range); + var newRanges = range.complementIntervals(coveredRanges); + coveredRanges.push(range); + coveredRanges = ContigInterval.coalesce(coveredRanges); + + for (var newRange of newRanges) { + fetch(newRange); + } + }).done(); + }, + // The ranges passed to these methods are 0-based + getRange, + getRangeAsString, + contigList: () => contigList, + normalizeRange, + + // These are here to make Flow happy. + on: () => {}, + once: () => {}, + off: () => {}, + trigger: () => {} + }; + _.extend(o, Events); // Make this an event emitter + return o; +}; + +function create(data: {url:string, contigList: SequenceRecord[]}): TwoBitSource { + var urlPrefix = data.url; + var contigList = data.contigList; + + // verify data was correctly set + if (!urlPrefix) { + throw new Error(`Missing URL from track: ${JSON.stringify(data)}`); + } + // verify contiglist was correctly set + if (!contigList) { + throw new Error(`Missing ContigList from track: ${JSON.stringify(data)}`); + } + return createFromReferenceUrl(new Sequence(new RemoteRequest(urlPrefix), contigList)); +} + +// Getter/setter for base pairs per fetch. +// This should only be used for testing. +function testBasePairsToFetch(num?: number): any { + if (num) { + BASE_PAIRS_PER_FETCH = num; + } else { + return BASE_PAIRS_PER_FETCH; + } +} + +// Getter/setter for base pairs per fetch. +// This should only be used for testing. +function testBasePairsToFetch(num?: number): any { + if (num) { + BASE_PAIRS_PER_FETCH = num; + } else { + return BASE_PAIRS_PER_FETCH; + } +} + +module.exports = { + create, + createFromReferenceUrl, + testBasePairsToFetch +}; diff --git a/src/main/sources/TwoBitDataSource.js b/src/main/sources/TwoBitDataSource.js index 59c27049..acb42b5c 100644 --- a/src/main/sources/TwoBitDataSource.js +++ b/src/main/sources/TwoBitDataSource.js @@ -23,6 +23,7 @@ import ContigInterval from '../ContigInterval'; import TwoBit from '../data/TwoBit'; import RemoteFile from '../RemoteFile'; import SequenceStore from '../SequenceStore'; +import type {SequenceRecord} from '../data/Sequence'; import utils from '../utils'; @@ -39,7 +40,7 @@ export type TwoBitSource = { rangeChanged: (newRange: GenomeRange) => void; getRange: (range: GenomeRange) => {[key:string]: ?string}; getRangeAsString: (range: GenomeRange) => string; - contigList: () => string[]; + contigList: () => SequenceRecord[]; normalizeRange: (range: GenomeRange) => Q.Promise; on: (event: string, handler: Function) => void; once: (event: string, handler: Function) => void; @@ -94,11 +95,13 @@ var createFromTwoBitFile = function(remoteSource: TwoBit): TwoBitSource { // This either adds or removes a 'chr' as needed. function normalizeRangeSync(range: GenomeRange): GenomeRange { - if (contigList.indexOf(range.contig) >= 0) { + var contigIdx = _.map(contigList, contig => contig.name).indexOf(range.contig); + if (contigIdx >= 0) { return range; } var altContig = utils.altContigName(range.contig); - if (contigList.indexOf(altContig) >= 0) { + var altIdx = _.map(contigList, contig => contig.name).indexOf(altContig); + if (altIdx >= 0) { return { contig: altContig, start: range.start, diff --git a/src/main/sources/VariantDataSource.js b/src/main/sources/VariantDataSource.js new file mode 100644 index 00000000..9e8a603e --- /dev/null +++ b/src/main/sources/VariantDataSource.js @@ -0,0 +1,145 @@ +/** + * The "glue" between TwoBit.js and GenomeTrack.js. + * + * GenomeTrack is pure view code -- it renders data which is already in-memory + * in the browser. + * + * TwoBit is purely for data parsing and fetching. It only knows how to return + * promises for various genome features. + * + * This code acts as a bridge between the two. It maintains a local version of + * the data, fetching remote data and informing the view when it becomes + * available. + * + * @flow + */ +'use strict'; + +import type {Variant, VariantContext} from '../data/vcf'; + +import Q from 'q'; +import _ from 'underscore'; +import {Events} from 'backbone'; +import {ResolutionCache} from '../ResolutionCache'; + +import ContigInterval from '../ContigInterval'; +import type {VcfDataSource} from './VcfDataSource'; +import {RemoteRequest} from '../RemoteRequest'; + +var BASE_PAIRS_PER_FETCH = 10000; + +function expandRange(range: ContigInterval) { + var roundDown = x => x - x % BASE_PAIRS_PER_FETCH; + var newStart = Math.max(1, roundDown(range.start())), + newStop = roundDown(range.stop() + BASE_PAIRS_PER_FETCH - 1); + + return new ContigInterval(range.contig, newStart, newStop); +} + +function keyFunction(vc: VariantContext): string { + return `${vc.variant.contig}:${vc.variant.position}`; +} + +function filterFunction(range: ContigInterval, vc: VariantContext): boolean { + return range.chrContainsLocus(vc.variant.contig, vc.variant.position); +} + +function createFromVariantUrl(remoteSource: RemoteRequest, + samples?: string[]): VcfDataSource { + + var cache: ResolutionCache = + new ResolutionCache(filterFunction, keyFunction); + + function fetch(range: GenomeRange) { + var startTimeMilliseconds = new Date().getTime(); + var interval = new ContigInterval(range.contig, range.start, range.stop); + + // Check if this interval is already in the cache. + if (cache.coversRange(interval)) { + console.info(`Time to get variants from cache:", ${new Date().getTime() - startTimeMilliseconds}`); + return Q.when(); + } + + // modify endpoint to calculate coverage using binning + var resolution = ResolutionCache.getResolution(interval.interval); + var endpointModifier = `binning=${resolution}`; + + interval = expandRange(interval); + + // get all smaller intervals not yet covered in cache + var newRanges = cache.complementInterval(interval, resolution); + + // "Cover" the range immediately to prevent duplicate fetches. + // Because interval is expanded, make sure to use original resolution + cache.coverRange(interval, resolution); + o.trigger('networkprogress', newRanges.length); + return Q.all(newRanges.map(range => + remoteSource.getFeaturesInRange(range, endpointModifier).then(e => { + var response = e.response; + if (response !== null) { + // parse VariantContexts + var variants = _.map(response, v => JSON.parse(v)); + variants.forEach(v => cache.put(v, resolution)); + } + console.info(`Fetched variants from server:", ${new Date().getTime() - startTimeMilliseconds}`); + o.trigger('networkdone'); + o.trigger('newdata', interval); + }))); + } + + function getVariantsInRange(range: ContigInterval, resolution: ?number): Variant[] { + if (!range) return []; // XXX why would this happen? + var data = cache.get(range, resolution); + var sorted = data.sort((a, b) => a.variant.position - b.variant.position); + return _.map(sorted, s => s.variant); + } + + function getGenotypesInRange(range: ContigInterval, resolution: ?number): VariantContext[] { + if (!range || !samples) return []; // if no samples are specified + var data = cache.get(range, resolution); + var sorted = data.sort((a, b) => a.variant.position - b.variant.position); + return sorted; + } + + function getSamples(): string[] { + if (!samples) { + throw new Error("No samples for genotypes"); + } else { + return samples; + } + } + + var o = { + rangeChanged: function(newRange: GenomeRange) { + fetch(newRange).done(); + }, + getVariantsInRange, + getGenotypesInRange, + getSamples, + + // These are here to make Flow happy. + on: () => {}, + off: () => {}, + trigger: () => {} + }; + _.extend(o, Events); // Make this an event emitter + + return o; +} + +function create(data: {url?:string, samples?:string[]}): VcfDataSource { + if (!data.url) { + throw new Error(`Missing URL from track: ${JSON.stringify(data)}`); + } + if (!data.samples) { + console.log("no genotype samples provided"); + } + var endpoint = new RemoteRequest(data.url, BASE_PAIRS_PER_FETCH); + return createFromVariantUrl(endpoint, data.samples); +} + + +module.exports = { + create, + createFromVariantUrl +}; diff --git a/src/main/sources/VcfDataSource.js b/src/main/sources/VcfDataSource.js index a8329361..39c10fc1 100644 --- a/src/main/sources/VcfDataSource.js +++ b/src/main/sources/VcfDataSource.js @@ -5,7 +5,7 @@ */ 'use strict'; -import type {Variant} from '../data/vcf'; +import type {Variant, VariantContext} from '../data/vcf'; import Events from 'backbone'; import _ from 'underscore'; @@ -18,7 +18,9 @@ import VcfFile from '../data/vcf'; export type VcfDataSource = { rangeChanged: (newRange: GenomeRange) => void; - getFeaturesInRange: (range: ContigInterval) => Variant[]; + getVariantsInRange: (range: ContigInterval) => Variant[]; + getGenotypesInRange: (range: ContigInterval) => VariantContext[]; + getSamples: () => string[]; on: (event: string, handler: Function) => void; off: (event: string) => void; trigger: (event: string, ...args:any) => void; @@ -71,16 +73,26 @@ function createFromVcfFile(remoteSource: VcfFile): VcfDataSource { }); } - function getFeaturesInRange(range: ContigInterval): Variant[] { + function getVariantsInRange(range: ContigInterval): Variant[] { if (!range) return []; // XXX why would this happen? return _.filter(variants, v => range.chrContainsLocus(v.contig, v.position)); } + function getGenotypesInRange(range: ContigInterval): VariantContext[] { + throw new Error(`Function getGenotypesInRange not implemented`); + } + + function getSamples(): string[] { + throw new Error(`Function getSamples not implemented`); + } + var o = { rangeChanged: function(newRange: GenomeRange) { fetch(newRange).done(); }, - getFeaturesInRange, + getVariantsInRange, + getGenotypesInRange, + getSamples, // These are here to make Flow happy. on: () => {}, diff --git a/src/main/style.js b/src/main/style.js index eb0f0c34..13f353ec 100644 --- a/src/main/style.js +++ b/src/main/style.js @@ -4,16 +4,15 @@ * * @flow */ - -"use strict"; +'use strict'; module.exports = { // Colors for individual base pairs BASE_COLORS: { - 'A': '#188712', - 'G': '#C45C16', - 'C': '#0600F9', - 'T': '#F70016', + 'A': '#5050FF', //'#188712', + 'G': '#00C000', //'#C45C16', + 'C': '#E00000', //'#0600F9', + 'T': '#E6E600',// '#F70016', 'U': '#F70016', 'N': 'black' }, @@ -41,6 +40,7 @@ module.exports = { COVERAGE_FONT_STYLE: `bold 9px 'Helvetica Neue', Helvetica, Arial, sans-serif`, COVERAGE_FONT_COLOR: 'black', COVERAGE_TICK_LENGTH: 5, + COVERAGE_PADDING: 10, // space between axis ticks and text COVERAGE_TEXT_PADDING: 3, // space between axis ticks and text COVERAGE_TEXT_Y_OFFSET: 3, // so that ticks and texts align better COVERAGE_BIN_COLOR: '#a0a0a0', @@ -61,8 +61,11 @@ module.exports = { LOC_FONT_COLOR: 'black', // Variant Track - VARIANT_STROKE: 'blue', - VARIANT_FILL: '#ddd', VARIANT_HEIGHT: 14, + // Genotype Track + GENOTYPE_SPACING: 1, + GENOTYPE_HEIGHT: 10, + GENOTYPE_FILL: '#999999', + BACKGROUND_FILL: '#f2f2f2', }; diff --git a/src/main/viz/CoverageCache.js b/src/main/viz/CoverageCache.js index 3c66d182..0a4cf341 100644 --- a/src/main/viz/CoverageCache.js +++ b/src/main/viz/CoverageCache.js @@ -1,5 +1,5 @@ /** - * Data management for CoverageTrack. + * Data management for PileupCoverageTrack. * * This class tracks counts and mismatches at each locus. * diff --git a/src/main/viz/CoverageTrack.js b/src/main/viz/CoverageTrack.js index 7b4a2dcc..97f3883e 100644 --- a/src/main/viz/CoverageTrack.js +++ b/src/main/viz/CoverageTrack.js @@ -4,12 +4,14 @@ */ 'use strict'; -import type {Alignment, AlignmentDataSource} from '../Alignment'; import type Interval from '../Interval'; import type {TwoBitSource} from '../sources/TwoBitDataSource'; import type {DataCanvasRenderingContext2D} from 'data-canvas'; -import type {BinSummary} from './CoverageCache'; import type {Scale} from './d3utils'; +import type {CoverageDataSource} from '../sources/CoverageDataSource'; +import type {VizProps} from '../VisualizationWrapper'; +import RemoteRequest from '../RemoteRequest'; +import type {PositionCount} from '../sources/CoverageDataSource'; import React from 'react'; import scale from '../scale'; @@ -18,28 +20,20 @@ import d3utils from './d3utils'; import _ from 'underscore'; import dataCanvas from 'data-canvas'; import canvasUtils from './canvas-utils'; -import CoverageCache from './CoverageCache'; -import TiledCanvas from './TiledCanvas'; import style from '../style'; import ContigInterval from '../ContigInterval'; - -// Basic setup (TODO: make this configurable by the user) -const SHOW_MISMATCHES = true; - -// Only show mismatch information when there are more than this many -// reads supporting that mismatch. -const MISMATCH_THRESHOLD = 1; +import TiledCanvas from './TiledCanvas'; +import type {State, NetworkStatus} from './pileuputils'; class CoverageTiledCanvas extends TiledCanvas { height: number; options: Object; - cache: CoverageCache; + source: CoverageDataSource; - constructor(cache: CoverageCache, height: number, options: Object) { + constructor(source: CoverageDataSource, height: number, options: Object) { super(); - - this.cache = cache; + this.source = source; this.height = Math.max(1, height); this.options = options; } @@ -54,146 +48,135 @@ class CoverageTiledCanvas extends TiledCanvas { this.options = options; } - yScaleForRef(ref: string): (y: number) => number { - var maxCoverage = this.cache.maxCoverageForRef(ref); + yScaleForRef(range: ContigInterval, resolution: ?number): (y: number) => number { + var maxCoverage = this.source.maxCoverage(range, resolution); - var padding = 10; // TODO: move into style return scale.linear() .domain([maxCoverage, 0]) - .range([padding, this.height - padding]) + .range([style.COVERAGE_PADDING, this.height - style.COVERAGE_PADDING]) .nice(); } + // This is called by TiledCanvas over all tiles in a range render(ctx: DataCanvasRenderingContext2D, xScale: (x: number)=>number, - range: ContigInterval) { - var bins = this.cache.binsForRef(range.contig); - var yScale = this.yScaleForRef(range.contig); + range: ContigInterval, + originalRange: ?ContigInterval, + resolution: ?number) { var relaxedRange = new ContigInterval( - range.contig, range.start() - 1, range.stop() + 1); - renderBars(ctx, xScale, yScale, relaxedRange, bins, this.options); + range.contig, Math.max(1, range.start() - 1), range.stop() + 1); + var bins = this.source.getCoverageInRange(relaxedRange, resolution); + + // if original range is not set, use tiled range which is a subset of originalRange + if (!originalRange) { + originalRange = range; + } + + var yScale = this.yScaleForRef(originalRange, resolution); + renderBars(ctx, xScale, yScale, relaxedRange, bins, resolution, this.options); } } -// Draw coverage bins & mismatches +// Draw coverage bins function renderBars(ctx: DataCanvasRenderingContext2D, xScale: (num: number) => number, yScale: (num: number) => number, range: ContigInterval, - bins: {[key: number]: BinSummary}, + bins: PositionCount[], + resolution: ?number, options: Object) { if (_.isEmpty(bins)) return; + // make sure bins are sorted by position + bins = _.sortBy(bins, x => x.start); + var barWidth = xScale(1) - xScale(0); var showPadding = (barWidth > style.COVERAGE_MIN_BAR_WIDTH_FOR_GAP); var padding = showPadding ? 1 : 0; - var binPos = function(pos: number, count: number) { + var binPos = function(ps: PositionCount) { // Round to integer coordinates for crisp lines, without aliasing. - var barX1 = Math.round(xScale(1 + pos)), - barX2 = Math.round(xScale(2 + pos)) - padding, - barY = Math.round(yScale(count)); + var barX1 = Math.round(xScale(ps.start)), + barX2 = Math.max(barX1 + 2, Math.round(xScale(ps.end)) - padding), // make sure bar is >= 1px + barY = Math.round(yScale(ps.count)); return {barX1, barX2, barY}; }; - var mismatchBins = ({} : {[key:number]: BinSummary}); // keep track of which ones have mismatches var vBasePosY = yScale(0); // the very bottom of the canvas - var start = range.start(), - stop = range.stop(); - let {barX1} = binPos(start, (start in bins) ? bins[start].count : 0); + + // go to the first bin in dataset (specified by the smallest start position) ctx.fillStyle = style.COVERAGE_BIN_COLOR; ctx.beginPath(); - ctx.moveTo(barX1, vBasePosY); - for (var pos = start; pos < stop; pos++) { - var bin = bins[pos]; - if (!bin) continue; - ctx.pushObject(bin); - let {barX1, barX2, barY} = binPos(pos, bin.count); - ctx.lineTo(barX1, barY); - ctx.lineTo(barX2, barY); - if (showPadding) { - ctx.lineTo(barX2, vBasePosY); - ctx.lineTo(barX2 + 1, vBasePosY); - } - if (SHOW_MISMATCHES && !_.isEmpty(bin.mismatches)) { - mismatchBins[pos] = bin; - } + bins.forEach(bin => { + ctx.pushObject(bin); + let {barX1, barX2, barY} = binPos(bin); + ctx.moveTo(barX1, vBasePosY); // start at bottom left of bar + ctx.lineTo(barX1, barY); // left edge of bar + ctx.lineTo(barX2, barY); // top of bar + ctx.lineTo(barX2, vBasePosY); // right edge of the right bar. ctx.popObject(); - } - let {barX2} = binPos(stop, (stop in bins) ? bins[stop].count : 0); - ctx.lineTo(barX2, vBasePosY); // right edge of the right bar. + }); ctx.closePath(); ctx.fill(); - - // Now render the mismatches - _.each(mismatchBins, (bin, pos) => { - if (!bin.mismatches) return; // this is here for Flow; it can't really happen. - const mismatches = _.clone(bin.mismatches); - pos = Number(pos); // object keys are strings, not numbers. - - // If this is a high-frequency variant, add in the reference. - var mismatchCount = _.reduce(mismatches, (x, y) => x + y); - var mostFrequentMismatch = _.max(mismatches); - if (mostFrequentMismatch > MISMATCH_THRESHOLD && - mismatchCount > options.vafColorThreshold * bin.count && - mismatchCount < bin.count) { - if (bin.ref) { // here for flow; can't realy happen - mismatches[bin.ref] = bin.count - mismatchCount; - } - } - - let {barX1, barX2} = binPos(pos, bin.count); - ctx.pushObject(bin); - var countSoFar = 0; - _.chain(mismatches) - .map((count, base) => ({count, base})) // pull base into the object - .filter(({count}) => count > MISMATCH_THRESHOLD) - .sortBy(({count}) => -count) // the most common mismatch at the bottom - .each(({count, base}) => { - var misMatchObj = {position: 1 + pos, count, base}; - ctx.pushObject(misMatchObj); // for debugging and click-tracking - - ctx.fillStyle = style.BASE_COLORS[base]; - var y = yScale(countSoFar); - ctx.fillRect(barX1, - y, - Math.max(1, barX2 - barX1), // min width of 1px - yScale(countSoFar + count) - y); - countSoFar += count; - - ctx.popObject(); - }); - ctx.popObject(); - }); } -type Props = { - width: number; - height: number; - range: GenomeRange; - source: AlignmentDataSource; - referenceSource: TwoBitSource; - options: { - vafColorThreshold: number - } -}; - class CoverageTrack extends React.Component { - props: Props; - state: void; - cache: CoverageCache; - tiles: CoverageTiledCanvas; + props: VizProps & { source: CoverageDataSource }; + state: State; static defaultOptions: Object; + tiles: CoverageTiledCanvas; - constructor(props: Props) { + constructor(props: VizProps) { super(props); + this.state = { + networkStatus: null + }; } render(): any { - return ; + // These styles allow vertical scrolling to see the full pileup. + // Adding a vertical scrollbar shrinks the visible area, but we have to act + // as though it doesn't, since adjusting the scale would put it out of sync + // with other tracks. + var containerStyles = { + 'height': '100%' + }; + var statusEl = null, + networkStatus = this.state.networkStatus; + if (networkStatus) { + statusEl = ( +
+
+ Loading coverage… +
+
+ ); + } + + var rangeLength = this.props.range.stop - this.props.range.start; + // If range is too large, do not render 'canvas' + if (rangeLength > RemoteRequest.MONSTER_REQUEST) { + return ( +
+
+ Zoom in to see coverage +
+ +
+ ); + } else { + return ( +
+ {statusEl} +
+ +
+
+ ); + } } getScale(): Scale { @@ -201,14 +184,12 @@ class CoverageTrack extends React.Component { } componentDidMount() { - this.cache = new CoverageCache(this.props.referenceSource); - this.tiles = new CoverageTiledCanvas(this.cache, this.props.height, this.props.options); + this.tiles = new CoverageTiledCanvas(this.props.source, this.props.height, this.props.options); this.props.source.on('newdata', range => { - var oldMax = this.cache.maxCoverageForRef(range.contig); - this.props.source.getAlignmentsInRange(range) - .forEach(read => this.cache.addAlignment(read)); - var newMax = this.cache.maxCoverageForRef(range.contig); + var oldMax = this.props.source.maxCoverage(range); + this.props.source.getCoverageInRange(range); + var newMax = this.props.source.maxCoverage(range); if (oldMax != newMax) { this.tiles.invalidateAll(); @@ -217,12 +198,16 @@ class CoverageTrack extends React.Component { } this.visualizeCoverage(); }); - - this.props.referenceSource.on('newdata', range => { - this.cache.updateMismatches(range); + this.props.source.on('newdata', range => { this.tiles.invalidateRange(range); this.visualizeCoverage(); }); + this.props.source.on('networkprogress', e => { + this.setState({networkStatus: e}); + }); + this.props.source.on('networkdone', e => { + this.setState({networkStatus: null}); + }); } componentDidUpdate(prevProps: any, prevState: any) { @@ -255,20 +240,23 @@ class CoverageTrack extends React.Component { canvasUtils.drawLine(ctx, 0, tickPosY, style.COVERAGE_TICK_LENGTH, tickPosY); ctx.popObject(); - var tickLabel = tick + 'X'; - ctx.pushObject({value: tick, label: tickLabel, type: 'label'}); - // Now print the coverage information - ctx.font = style.COVERAGE_FONT_STYLE; - var textPosX = style.COVERAGE_TICK_LENGTH + style.COVERAGE_TEXT_PADDING, - textPosY = tickPosY + style.COVERAGE_TEXT_Y_OFFSET; - // The stroke creates a border around the text to make it legible over the bars. - ctx.strokeStyle = 'white'; - ctx.lineWidth = 2; - ctx.strokeText(tickLabel, textPosX, textPosY); - ctx.lineWidth = 1; - ctx.fillStyle = style.COVERAGE_FONT_COLOR; - ctx.fillText(tickLabel, textPosX, textPosY); - ctx.popObject(); + if (tick > 0) { + var tickLabel = tick + 'X'; + ctx.pushObject({value: tick, label: tickLabel, type: 'label'}); + // Now print the coverage information + ctx.font = style.COVERAGE_FONT_STYLE; + var textPosX = style.COVERAGE_TICK_LENGTH + style.COVERAGE_TEXT_PADDING, + textPosY = tickPosY + style.COVERAGE_TEXT_Y_OFFSET; + // The stroke creates a border around the text to make it legible over the bars. + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.strokeText(tickLabel, textPosX, textPosY); + ctx.lineWidth = 1; + ctx.fillStyle = style.COVERAGE_FONT_COLOR; + ctx.fillText(tickLabel, textPosX, textPosY); + ctx.popObject(); + } + }); } @@ -279,7 +267,7 @@ class CoverageTrack extends React.Component { range = ContigInterval.fromGenomeRange(this.props.range); // Hold off until height & width are known. - if (width === 0) return; + if (width === 0 || typeof canvas == 'undefined') return; d3utils.sizeCanvas(canvas, width, height); var ctx = dataCanvas.getDataContext(this.getContext()); @@ -287,7 +275,7 @@ class CoverageTrack extends React.Component { ctx.reset(); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - var yScale = this.tiles.yScaleForRef(range.contig); + var yScale = this.tiles.yScaleForRef(range); this.tiles.renderToScreen(ctx, range, this.getScale()); this.renderTicks(ctx, yScale); @@ -303,37 +291,23 @@ class CoverageTrack extends React.Component { // No need to render the scene to determine what was clicked. var range = ContigInterval.fromGenomeRange(this.props.range), xScale = this.getScale(), - bins = this.cache.binsForRef(range.contig), + bins = this.props.source.getCoverageInRange(range), pos = Math.floor(xScale.invert(x)) - 1, bin = bins[pos]; var alert = window.alert || console.log; if (bin) { - var mmCount = bin.mismatches ? _.reduce(bin.mismatches, (a, b) => a + b) : 0; - var ref = bin.ref || this.props.referenceSource.getRangeAsString( - {contig: range.contig, start: pos, stop: pos}); - // Construct a JSON object to show the user. var messageObject = _.extend( { 'position': range.contig + ':' + (1 + pos), 'read depth': bin.count - }, - bin.mismatches); - messageObject[ref] = bin.count - mmCount; + }); alert(JSON.stringify(messageObject, null, ' ')); } } } CoverageTrack.displayName = 'coverage'; -CoverageTrack.defaultOptions = { - // Color the reference base in the bar chart when the Variant Allele Fraction - // exceeds this amount. When there are >=2 agreeing mismatches, they are - // always rendered. But for mismatches below this threshold, the reference is - // not colored in the bar chart. This draws attention to high-VAF mismatches. - vafColorThreshold: 0.2 -}; - module.exports = CoverageTrack; diff --git a/src/main/viz/FeatureTrack.js b/src/main/viz/FeatureTrack.js new file mode 100644 index 00000000..e3160c0a --- /dev/null +++ b/src/main/viz/FeatureTrack.js @@ -0,0 +1,213 @@ +/** + * Visualization of features, including exons and coding regions. + * @flow + */ +'use strict'; + +import type {Feature, FeatureDataSource} from '../sources/FeatureDataSource'; +import type {DataCanvasRenderingContext2D} from 'data-canvas'; + +import type {VizProps} from '../VisualizationWrapper'; +import type {Scale} from './d3utils'; + +import React from 'react'; +import shallowEquals from 'shallow-equals'; +import _ from 'underscore'; + +import d3utils from './d3utils'; +import RemoteRequest from '../RemoteRequest'; +import ContigInterval from '../ContigInterval'; +import canvasUtils from './canvas-utils'; +import TiledCanvas from './TiledCanvas'; +import dataCanvas from 'data-canvas'; +import style from '../style'; +import utils from '../utils'; +import type {State, NetworkStatus} from './pileuputils'; + +class FeatureTiledCanvas extends TiledCanvas { + options: Object; + source: FeatureDataSource; + + constructor(source: FeatureDataSource, options: Object) { + super(); + this.source = source; + this.options = options; + } + + update(newOptions: Object) { + this.options = newOptions; + } + + // TODO: can update to handle overlapping features + heightForRef(ref: string): number { + return style.VARIANT_HEIGHT; + } + + render(ctx: DataCanvasRenderingContext2D, + scale: (x: number)=>number, + range: ContigInterval, + originalRange: ?ContigInterval, + resolution: ?number) { + var relaxedRange = + new ContigInterval(range.contig, range.start() - 1, range.stop() + 1); + var vFeatures = this.source.getFeaturesInRange(relaxedRange, resolution); + renderFeatures(ctx, scale, relaxedRange, vFeatures); + } +} + +// Draw features +function renderFeatures(ctx: DataCanvasRenderingContext2D, + scale: (num: number) => number, + range: ContigInterval, + features: Feature[]) { + + ctx.font = `${style.GENE_FONT_SIZE}px ${style.GENE_FONT}`; + ctx.textAlign = 'center'; + + features.forEach(feature => { + var position = new ContigInterval(feature.contig, feature.start, feature.stop); + if (!position.chrIntersects(range)) return; + ctx.pushObject(feature); + ctx.lineWidth = 1; + + // Create transparency value based on score. Score of <= 200 is the same transparency. + var alphaScore = Math.max(feature.score / 1000.0, 0.2); + ctx.fillStyle = 'rgba(0, 0, 0, ' + alphaScore + ')'; + + var x = Math.round(scale(feature.start)); + var width = Math.ceil(scale(feature.stop) - scale(feature.start)); + ctx.fillRect(x - 0.5, 0, width, style.VARIANT_HEIGHT); + ctx.popObject(); + }); +} + +class FeatureTrack extends React.Component { + props: VizProps & { source: FeatureDataSource }; + state: State; + tiles: FeatureTiledCanvas; + + constructor(props: VizProps) { + super(props); + this.state = { + networkStatus: null + }; + } + + render(): any { + var statusEl = null, + networkStatus = this.state.networkStatus; + if (networkStatus) { + statusEl = ( +
+
+ Loading features… +
+
+ ); + } + var rangeLength = this.props.range.stop - this.props.range.start; + // If range is too large, do not render 'canvas' + if (rangeLength > RemoteRequest.MONSTER_REQUEST) { + return ( +
+
+ Zoom in to see features +
+ +
+ ); + } else { + return ( +
+ {statusEl} +
+ +
+
+ ); + } + } + + componentDidMount() { + this.tiles = new FeatureTiledCanvas(this.props.source, this.props.options); + + // Visualize new reference data as it comes in from the network. + this.props.source.on('newdata', (range) => { + this.tiles.invalidateRange(range); + this.updateVisualization(); + }); + this.props.source.on('networkprogress', e => { + this.setState({networkStatus: e}); + }); + this.props.source.on('networkdone', e => { + this.setState({networkStatus: null}); + }); + + this.updateVisualization(); + } + + getScale(): Scale { + return d3utils.getTrackScale(this.props.range, this.props.width); + } + + componentDidUpdate(prevProps: any, prevState: any) { + if (!shallowEquals(this.props, prevProps) || + !shallowEquals(this.state, prevState)) { + this.tiles.update(this.props.options); + this.tiles.invalidateAll(); + this.updateVisualization(); + } + } + + updateVisualization() { + var canvas = (this.refs.canvas : HTMLCanvasElement), + {width, height} = this.props, + genomeRange = this.props.range; + + var range = new ContigInterval(genomeRange.contig, genomeRange.start, genomeRange.stop); + + // Hold off until height & width are known. + if (width === 0 || typeof canvas == 'undefined') return; + d3utils.sizeCanvas(canvas, width, height); + + var ctx = dataCanvas.getDataContext(canvasUtils.getContext(canvas)); + ctx.reset(); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + this.tiles.renderToScreen(ctx, range, this.getScale()); + ctx.restore(); + + } + + handleClick(reactEvent: any) { + var ev = reactEvent.nativeEvent, + x = ev.offsetX; + + var genomeRange = this.props.range, + // allow some buffering so click isn't so sensitive + range = new ContigInterval(genomeRange.contig, genomeRange.start-1, genomeRange.stop+1), + scale = this.getScale(), + // leave padding of 2px to reduce click specificity + clickStart = Math.floor(scale.invert(x)) - 2, + clickEnd = clickStart + 2, + // If click-tracking gets slow, this range could be narrowed to one + // closer to the click coordinate, rather than the whole visible range. + vFeatures = this.props.source.getFeaturesInRange(range); + var feature = _.find(vFeatures, f => utils.tupleRangeOverlaps([[f.start], [f.stop]], [[clickStart], [clickEnd]])); + var alert = window.alert || console.log; + if (feature) { + // Construct a JSON object to show the user. + var messageObject = _.extend( + { + 'id': feature.id, + 'range': `${feature.contig}:${feature.start}-${feature.stop}`, + 'score': feature.score + }); + alert(JSON.stringify(messageObject, null, ' ')); + } + } +} + +FeatureTrack.displayName = 'features'; + +module.exports = FeatureTrack; diff --git a/src/main/viz/GenomeTrack.js b/src/main/viz/GenomeTrack.js index 12d570a0..743f6b8a 100644 --- a/src/main/viz/GenomeTrack.js +++ b/src/main/viz/GenomeTrack.js @@ -30,7 +30,6 @@ function renderGenome(ctx: DataCanvasRenderingContext2D, var pxPerLetter = scale(1) - scale(0); var mode = DisplayMode.getDisplayMode(pxPerLetter); var showText = DisplayMode.isText(mode); - if (mode != DisplayMode.HIDDEN) { ctx.textAlign = 'center'; if (mode == DisplayMode.LOOSE) { @@ -94,7 +93,7 @@ class GenomeTiledCanvas extends TiledCanvas { // The +/-1 ensures that partially-visible bases on the edge are rendered. var genomeRange = { contig: range.contig, - start: range.start() - 1, + start: Math.max(0, (range.start() - 1)), stop: range.stop() + 1 }; var basePairs = this.source.getRangeAsString(genomeRange); diff --git a/src/main/viz/GenotypeTrack.js b/src/main/viz/GenotypeTrack.js new file mode 100644 index 00000000..7fc3811a --- /dev/null +++ b/src/main/viz/GenotypeTrack.js @@ -0,0 +1,297 @@ +/** + * Visualization of genotypes + * @flow + */ +'use strict'; + +import type {VcfDataSource} from '../sources/VcfDataSource'; +import type {VariantContext} from '../data/vcf'; +import type {DataCanvasRenderingContext2D} from 'data-canvas'; +import type {VizProps} from '../VisualizationWrapper'; +import type {Scale} from './d3utils'; + +import React from 'react'; +import _ from 'underscore'; + + +import d3utils from './d3utils'; +import shallowEquals from 'shallow-equals'; +import ContigInterval from '../ContigInterval'; +import canvasUtils from './canvas-utils'; +import TiledCanvas from './TiledCanvas'; +import dataCanvas from 'data-canvas'; +import style from '../style'; +import utils from '../utils'; +import type {State, NetworkStatus} from './pileuputils'; + +var MONSTER_REQUEST = 10000; +var LABEL_WIDTH = 100; + +class GenotypeTiledCanvas extends TiledCanvas { + options: Object; + source: VcfDataSource; + sampleIds: string[]; + + constructor(source: VcfDataSource, sampleIds: string[], options: Object) { + super(); + this.source = source; + this.options = options; + this.sampleIds = sampleIds; + } + + update(newOptions: Object) { + this.options = newOptions; + } + + heightForRef(ref: string): number { + return yForRow(this.sampleIds.length); + } + + render(ctx: DataCanvasRenderingContext2D, + scale: (x: number)=>number, + range: ContigInterval, + originalRange: ?ContigInterval, + resolution: ?number) { + var relaxedRange = + new ContigInterval(range.contig, range.start() - 1, range.stop() + 1); + + // relaxed range is just for this tile. make sure to get resolution for whole + // viewing area + var vGenotypes = this.source.getGenotypesInRange(relaxedRange, resolution); + renderGenotypes(ctx, scale, relaxedRange, vGenotypes, this.sampleIds); + } +} +// Draw genotypes +function renderGenotypes(ctx: DataCanvasRenderingContext2D, + scale: (num: number) => number, + range: ContigInterval, + genotypes: VariantContext[], + sampleIds) { + // draw genotypes + genotypes.forEach(genotype => { + var variant = genotype.variant; + var keys = genotype.sampleIds; + ctx.pushObject(variant); + ctx.fillStyle = style.GENOTYPE_FILL; + ctx.strokeStyle = style.GENOTYPE_FILL; + var x = Math.round(scale(variant.position)); + var width = Math.round(scale(variant.end)) - x; + keys.forEach(sampleId => { + var y = yForRow(sampleIds.indexOf(sampleId)); + ctx.fillRect(x - 0.2, y, width, style.GENOTYPE_HEIGHT); + ctx.strokeRect(x - 0.2, y, width, style.GENOTYPE_HEIGHT); + }); + ctx.popObject(); + }); +} + +function yForRow(row) { + return row * (style.GENOTYPE_HEIGHT + style.GENOTYPE_SPACING); +} + +class GenotypeTrack extends React.Component { + props: VizProps & {source: VcfDataSource}; + state: State; + tiles: GenotypeTiledCanvas; + sampleIds: string[]; + + constructor(props: Object) { + super(props); + this.state = { + networkStatus: null + }; + this.sampleIds = props.source.getSamples(); + } + + render(): any { + // These styles allow vertical scrolling to see the full pileup. + // Adding a vertical scrollbar shrinks the visible area, but we have to act + // as though it doesn't, since adjusting the scale would put it out of sync + // with other tracks. + var containerStyles = { + 'height': '100%' + }; + + var labelStyles = { + 'float': 'left', + 'overflow': 'hidden', + 'width:': `${ LABEL_WIDTH }px` + }; + + var canvasStyles = { + 'overflow': 'hidden' + }; + + var statusEl = null, + networkStatus = this.state.networkStatus; + if (networkStatus) { + statusEl = ( +
+
+ Loading Genotypes… +
+
+ ); + } + var rangeLength = this.props.range.stop - this.props.range.start; + // If range is too large, do not render 'canvas' + if (rangeLength >= MONSTER_REQUEST) { + return ( +
+
+ Zoom in to see genotypes +
+ +
+ ); + } else { + return ( +
+ {statusEl} +
+
+
+
+
+ ); + } + } + + componentDidMount() { + this.tiles = new GenotypeTiledCanvas(this.props.source, + this.sampleIds, this.props.options); + + // Visualize new data as it comes in from the network. + this.props.source.on('newdata', (range) => { + this.tiles.invalidateRange(range); + this.updateVisualization(); + }); + this.props.source.on('networkprogress', e => { + this.setState({networkStatus: e}); + }); + this.props.source.on('networkdone', e => { + this.setState({networkStatus: null}); + }); + + this.updateVisualization(); + } + + getScale(): Scale { + return d3utils.getTrackScale(this.props.range, this.props.width - LABEL_WIDTH); + } + + componentDidUpdate(prevProps: any, prevState: any) { + if (!shallowEquals(prevProps, this.props) || + !shallowEquals(prevState, this.state)) { + this.tiles.update(this.props.options); + this.tiles.invalidateAll(); + this.updateVisualization(); + } + } + + // draws genotype lines to visually separate genotype rows + drawLines(ctx: DataCanvasRenderingContext2D) { + var width = this.props.width; + + // draw background for each row + if (this.sampleIds !== null) { + ctx.font = "9px Arial"; + this.sampleIds.forEach(sampleId => { + ctx.pushObject(sampleId); + var y = yForRow(this.sampleIds.indexOf(sampleId)); + ctx.fillStyle = style.BACKGROUND_FILL; + ctx.fillRect(0, y, width, style.GENOTYPE_HEIGHT); + ctx.popObject(); + }); + } + } + + // draws sample names on side bar. This needs to be only rendered once. + drawLabels() { + // if already drawn, return + var labelCanvas = (this.refs.labelCanvas : HTMLCanvasElement), + width = this.props.width; + + // Hold off until height & width are known. + if (width === 0 || typeof labelCanvas == 'undefined') return; + + var height = yForRow(this.sampleIds.length); + + + // only render once on load. + if (labelCanvas.clientHeight != height) { + var labelCtx = dataCanvas.getDataContext(canvasUtils.getContext(labelCanvas)); + d3utils.sizeCanvas(labelCanvas, LABEL_WIDTH, height); + + // draw label for each row + if (this.sampleIds !== null) { + labelCtx.font = "9px Arial"; + this.sampleIds.forEach(sampleId => { + labelCtx.pushObject(sampleId); + var y = yForRow(this.sampleIds.indexOf(sampleId)); + labelCtx.fillStyle = "black"; + labelCtx.fillText(sampleId, 0, y+style.GENOTYPE_HEIGHT); + labelCtx.popObject(); + }); + } + } + } + + updateVisualization() { + var canvas = (this.refs.canvas : HTMLCanvasElement), + width = this.props.width; + + // Hold off until height & width are known. + if (width === 0 || typeof canvas == 'undefined') return; + + var height = yForRow(this.sampleIds.length); + d3utils.sizeCanvas(canvas, width - LABEL_WIDTH, height); + + var ctx = dataCanvas.getDataContext(canvasUtils.getContext(canvas)); + this.drawLabels(); + this.renderScene(ctx); + } + + renderScene(ctx: DataCanvasRenderingContext2D) { + var range = this.props.range, + interval = new ContigInterval(range.contig, range.start, range.stop), + scale = this.getScale(); + + ctx.reset(); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + // render lines after rectangle has been cleared + this.drawLines(ctx); + + this.tiles.renderToScreen(ctx, interval, scale); + ctx.restore(); + } + + handleClick(reactEvent: any) { + var ev = reactEvent.nativeEvent, + x = ev.offsetX; + + var genomeRange = this.props.range, + // allow some buffering so click isn't so sensitive + range = new ContigInterval(genomeRange.contig, genomeRange.start-1, genomeRange.stop+1), + scale = this.getScale(), + // leave padding of 2px to reduce click specificity + clickStart = Math.floor(scale.invert(x)) - 2, + clickEnd = clickStart + 2, + // If click-tracking gets slow, this range could be narrowed to one + // closer to the click coordinate, rather than the whole visible range. + vGenotypes = this.props.source.getGenotypesInRange(range, 1); + + var genotype = _.find(vGenotypes, f => utils.tupleRangeOverlaps([[f.variant.position], [f.variant.end]], [[clickStart], [clickEnd]])); + var alert = window.alert || console.log; + if (genotype) { + var variantString = `variant: ${JSON.stringify(genotype.variant)}`; + var samples = `samples with variant: ${JSON.stringify(genotype.sampleIds)}`; + alert(`${variantString}\n${samples}`); + } + } +} + +GenotypeTrack.displayName = 'genotypes'; + +module.exports = GenotypeTrack; diff --git a/src/main/viz/PileupCoverageTrack.js b/src/main/viz/PileupCoverageTrack.js new file mode 100644 index 00000000..75466579 --- /dev/null +++ b/src/main/viz/PileupCoverageTrack.js @@ -0,0 +1,344 @@ +/** + * Coverage visualization of Alignment sources. + * @flow + */ +'use strict'; + +import type {Alignment, AlignmentDataSource} from '../Alignment'; +import type Interval from '../Interval'; +import GA4GHDataSource from '../sources/GA4GHDataSource'; +import type {TwoBitSource} from '../sources/TwoBitDataSource'; +import type {DataCanvasRenderingContext2D} from 'data-canvas'; +import type {BinSummary} from './CoverageCache'; +import type {Scale} from './d3utils'; + +import React from 'react'; +import scale from '../scale'; +import shallowEquals from 'shallow-equals'; +import d3utils from './d3utils'; +import _ from 'underscore'; +import dataCanvas from 'data-canvas'; +import canvasUtils from './canvas-utils'; +import CoverageCache from './CoverageCache'; +import TiledCanvas from './TiledCanvas'; +import style from '../style'; +import ContigInterval from '../ContigInterval'; + +// Basic setup (TODO: make this configurable by the user) +const SHOW_MISMATCHES = true; + +// Only show mismatch information when there are more than this many +// reads supporting that mismatch. +const MISMATCH_THRESHOLD = 1; + + +class CoverageTiledCanvas extends TiledCanvas { + height: number; + options: Object; + cache: CoverageCache; + + constructor(cache: CoverageCache, height: number, options: Object) { + super(); + + this.cache = cache; + this.height = Math.max(1, height); + this.options = options; + } + + heightForRef(ref: string): number { + return this.height; + } + + update(height: number, options: Object) { + // workaround for an issue in PhantomJS where height always comes out to zero. + this.height = Math.max(1, height); + this.options = options; + } + + yScaleForRef(ref: string, bottomPadding: number, topPadding:number): (y: number) => number { + var maxCoverage = this.cache.maxCoverageForRef(ref); + return scale.linear() + .domain([maxCoverage, 0]) + .range([bottomPadding, this.height - topPadding]) + .nice(); + } + + render(ctx: DataCanvasRenderingContext2D, + xScale: (x: number)=>number, + range: ContigInterval) { + var bins = this.cache.binsForRef(range.contig); + var yScale = this.yScaleForRef(range.contig, 0, 20); + var relaxedRange = new ContigInterval( + range.contig, range.start() - 1, range.stop() + 1); + renderBars(ctx, xScale, yScale, relaxedRange, bins, this.options); + } +} + + +// Draw coverage bins & mismatches +function renderBars(ctx: DataCanvasRenderingContext2D, + xScale: (num: number) => number, + yScale: (num: number) => number, + range: ContigInterval, + bins: {[key: number]: BinSummary}, + options: Object) { + if (_.isEmpty(bins)) return; + + var barWidth = xScale(1) - xScale(0); + var showPadding = (barWidth > style.COVERAGE_MIN_BAR_WIDTH_FOR_GAP); + var padding = showPadding ? 1 : 0; + + var binPos = function(pos: number, count: number) { + // Round to integer coordinates for crisp lines, without aliasing. + var barX1 = Math.round(xScale(1 + pos)), + barX2 = Math.round(xScale(2 + pos)) - padding, + barY = Math.round(yScale(count)); + return {barX1, barX2, barY}; + }; + + var mismatchBins = ({} : {[key:number]: BinSummary}); // keep track of which ones have mismatches + var vBasePosY = yScale(0); // the very bottom of the canvas + var start = range.start(), + stop = range.stop(); + let {barX1} = binPos(start, (start in bins) ? bins[start].count : 0); + ctx.fillStyle = style.COVERAGE_BIN_COLOR; + ctx.beginPath(); + ctx.moveTo(barX1, vBasePosY); + for (var pos = start; pos < stop; pos++) { + var bin = bins[pos]; + if (!bin) continue; + ctx.pushObject(bin); + let {barX1, barX2, barY} = binPos(pos, bin.count); + ctx.lineTo(barX1, barY); + ctx.lineTo(barX2, barY); + if (showPadding) { + ctx.lineTo(barX2, vBasePosY); + ctx.lineTo(barX2 + 1, vBasePosY); + } + + if (SHOW_MISMATCHES && !_.isEmpty(bin.mismatches)) { + mismatchBins[pos] = bin; + } + + ctx.popObject(); + } + let {barX2} = binPos(stop, (stop in bins) ? bins[stop].count : 0); + ctx.lineTo(barX2, vBasePosY); // right edge of the right bar. + ctx.closePath(); + ctx.fill(); + + // Now render the mismatches + _.each(mismatchBins, (bin, pos) => { + if (!bin.mismatches) return; // this is here for Flow; it can't really happen. + const mismatches = _.clone(bin.mismatches); + pos = Number(pos); // object keys are strings, not numbers. + + // If this is a high-frequency variant, add in the reference. + var mismatchCount = _.reduce(mismatches, (x, y) => x + y); + var mostFrequentMismatch = _.max(mismatches); + if (mostFrequentMismatch > MISMATCH_THRESHOLD && + mismatchCount > options.vafColorThreshold * bin.count && + mismatchCount < bin.count) { + if (bin.ref) { // here for flow; can't realy happen + mismatches[bin.ref] = bin.count - mismatchCount; + } + } + + let {barX1, barX2} = binPos(pos, bin.count); + ctx.pushObject(bin); + var countSoFar = 0; + _.chain(mismatches) + .map((count, base) => ({count, base})) // pull base into the object + .filter(({count}) => count >= MISMATCH_THRESHOLD) + .sortBy(({count}) => -count) // the most common mismatch at the bottom + .each(({count, base}) => { + var misMatchObj = {position: 1 + pos, count, base}; + ctx.pushObject(misMatchObj); // for debugging and click-tracking + + ctx.fillStyle = style.BASE_COLORS[base]; + var y = yScale(countSoFar); + ctx.fillRect(barX1, + y, + Math.max(1, barX2 - barX1), // min width of 1px + yScale(countSoFar + count) - y); + countSoFar += count; + + ctx.popObject(); + }); + ctx.popObject(); + }); +} + +type Props = { + width: number; + height: number; + range: GenomeRange; + source: AlignmentDataSource; + referenceSource: TwoBitSource; + options: { + vafColorThreshold: number + } +}; + +class PileupCoverageTrack extends React.Component { + props: Props; + state: void; + cache: CoverageCache; + tiles: CoverageTiledCanvas; + static defaultOptions: Object; + + constructor(props: Props) { + super(props); + } + + render(): any { + var rangeLength = this.props.range.stop - this.props.range.start; + // Render coverage if base pairs is less than threshold + if (rangeLength <= GA4GHDataSource.MAX_BASE_PAIRS_TO_FETCH) { + return ; + } else return
; + } + + getScale(): Scale { + return d3utils.getTrackScale(this.props.range, this.props.width); + } + + componentDidMount() { + this.cache = new CoverageCache(this.props.referenceSource); + this.tiles = new CoverageTiledCanvas(this.cache, this.props.height, this.props.options); + + this.props.source.on('newdata', range => { + var oldMax = this.cache.maxCoverageForRef(range.contig); + this.props.source.getAlignmentsInRange(range) + .forEach(read => this.cache.addAlignment(read)); + var newMax = this.cache.maxCoverageForRef(range.contig); + + if (oldMax != newMax) { + this.tiles.invalidateAll(); + } else { + this.tiles.invalidateRange(range); + } + this.visualizeCoverage(); + }); + + this.props.referenceSource.on('newdata', range => { + this.cache.updateMismatches(range); + this.tiles.invalidateRange(range); + this.visualizeCoverage(); + }); + } + + componentDidUpdate(prevProps: any, prevState: any) { + if (!shallowEquals(this.props, prevProps) || + !shallowEquals(this.state, prevState)) { + if (this.props.height != prevProps.height || + this.props.options != prevProps.options) { + this.tiles.update(this.props.height, this.props.options); + this.tiles.invalidateAll(); + } + this.visualizeCoverage(); + } + } + + getContext(): CanvasRenderingContext2D { + var canvas = (this.refs.canvas : HTMLCanvasElement); + // The typecast through `any` is because getContext could return a WebGL context. + var ctx = ((canvas.getContext('2d') : any) : CanvasRenderingContext2D); + return ctx; + } + + // Draw three ticks on the left to set the scale for the user + renderTicks(ctx: DataCanvasRenderingContext2D, yScale: (num: number)=>number) { + var axisMax = yScale.domain()[0]; + [0, Math.round(axisMax / 2), axisMax].forEach(tick => { + // Draw a line indicating the tick + ctx.pushObject({value: tick, type: 'tick'}); + var tickPosY = Math.round(yScale(tick)); + ctx.strokeStyle = style.COVERAGE_FONT_COLOR; + canvasUtils.drawLine(ctx, 0, tickPosY, style.COVERAGE_TICK_LENGTH, tickPosY); + ctx.popObject(); + + if (tick > 0) { + var tickLabel = tick + 'X'; + ctx.pushObject({value: tick, label: tickLabel, type: 'label'}); + // Now print the coverage information + ctx.font = style.COVERAGE_FONT_STYLE; + var textPosX = style.COVERAGE_TICK_LENGTH + style.COVERAGE_TEXT_PADDING, + textPosY = tickPosY + style.COVERAGE_TEXT_Y_OFFSET; + // The stroke creates a border around the text to make it legible over the bars. + ctx.strokeStyle = 'white'; + ctx.lineWidth = 2; + ctx.strokeText(tickLabel, textPosX, textPosY); + ctx.lineWidth = 1; + ctx.fillStyle = style.COVERAGE_FONT_COLOR; + ctx.fillText(tickLabel, textPosX, textPosY); + ctx.popObject(); + } + }); + } + + visualizeCoverage() { + var canvas = (this.refs.canvas : HTMLCanvasElement), + width = this.props.width, + height = this.props.height, + range = ContigInterval.fromGenomeRange(this.props.range); + + // Hold off until height & width are known. + if (width === 0 || typeof canvas == 'undefined') return; + d3utils.sizeCanvas(canvas, width, height); + + var ctx = dataCanvas.getDataContext(this.getContext()); + ctx.save(); + ctx.reset(); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + var yScale = this.tiles.yScaleForRef(range.contig, 10, 10); + + this.tiles.renderToScreen(ctx, range, this.getScale()); + this.renderTicks(ctx, yScale); + + ctx.restore(); + } + + handleClick(reactEvent: any) { + var ev = reactEvent.nativeEvent, + x = ev.offsetX; + + // It's simple to figure out which position was clicked using the x-scale. + // No need to render the scene to determine what was clicked. + var range = ContigInterval.fromGenomeRange(this.props.range), + xScale = this.getScale(), + bins = this.cache.binsForRef(range.contig), + pos = Math.floor(xScale.invert(x)) - 1, + bin = bins[pos]; + + var alert = window.alert || console.log; + if (bin) { + var mmCount = bin.mismatches ? _.reduce(bin.mismatches, (a, b) => a + b) : 0; + var ref = bin.ref || this.props.referenceSource.getRangeAsString( + {contig: range.contig, start: pos, stop: pos}); + + // Construct a JSON object to show the user. + var messageObject = _.extend( + { + 'position': range.contig + ':' + (1 + pos), + 'read depth': bin.count + }, + bin.mismatches); + messageObject[ref] = bin.count - mmCount; + alert(JSON.stringify(messageObject, null, ' ')); + } + } +} + +PileupCoverageTrack.displayName = 'coverage'; +PileupCoverageTrack.defaultOptions = { + // Color the reference base in the bar chart when the Variant Allele Fraction + // exceeds this amount. When there are >=2 agreeing mismatches, they are + // always rendered. But for mismatches below this threshold, the reference is + // not colored in the bar chart. This draws attention to high-VAF mismatches. + vafColorThreshold: 0.2 +}; + + +module.exports = PileupCoverageTrack; diff --git a/src/main/viz/PileupTrack.js b/src/main/viz/PileupTrack.js index b293f922..2ab76469 100644 --- a/src/main/viz/PileupTrack.js +++ b/src/main/viz/PileupTrack.js @@ -5,8 +5,8 @@ 'use strict'; import type {Strand, Alignment, AlignmentDataSource} from '../Alignment'; +import GA4GHDataSource from '../sources/GA4GHDataSource'; import type {TwoBitSource} from '../sources/TwoBitDataSource'; -import type {BasePair} from './pileuputils'; import type {VisualAlignment, VisualGroup, InsertStats} from './PileupCache'; import type {DataCanvasRenderingContext2D} from 'data-canvas'; import type Interval from '../Interval'; @@ -19,7 +19,8 @@ import _ from 'underscore'; import scale from '../scale'; import d3utils from './d3utils'; -import {CigarOp} from './pileuputils'; +import type {BasePair, State, NetworkStatus} from './pileuputils'; +import {CigarOp, formatStatus} from './pileuputils'; import ContigInterval from '../ContigInterval'; import DisplayMode from './DisplayMode'; import PileupCache from './PileupCache'; @@ -31,6 +32,12 @@ import style from '../style'; var READ_HEIGHT = 13; var READ_SPACING = 2; // vertical pixels between reads +var MAX_ROWS = 1000; // max rows to print pileup + +function pileupHeight(rows: number): number { + var modified_rows = Math.min(MAX_ROWS, rows); + return modified_rows * (READ_HEIGHT + READ_SPACING); +} var READ_STRAND_ARROW_WIDTH = 5; @@ -54,8 +61,7 @@ class PileupTiledCanvas extends TiledCanvas { } heightForRef(ref: string): number { - return this.cache.pileupHeightForRef(ref) * - (READ_HEIGHT + READ_SPACING); + return pileupHeight(this.cache.pileupHeightForRef(ref)); } render(ctx: DataCanvasRenderingContext2D, @@ -179,17 +185,19 @@ function renderPileup(ctx: DataCanvasRenderingContext2D, } else { ctx.fillStyle = style.ALIGNMENT_COLOR; } - var y = yForRow(vGroup.row); - ctx.pushObject(vGroup); - if (vGroup.insert) { - var span = vGroup.insert, - x1 = scale(span.start + 1), - x2 = scale(span.stop + 1); - ctx.fillRect(x1, y + READ_HEIGHT / 2 - 0.5, x2 - x1, 1); + if (vGroup.row <= MAX_ROWS) { + var y = yForRow(vGroup.row); + ctx.pushObject(vGroup); + if (vGroup.insert) { + var span = vGroup.insert, + x1 = scale(span.start + 1), + x2 = scale(span.stop + 1); + ctx.fillRect(x1, y + READ_HEIGHT / 2 - 0.5, x2 - x1, 1); + } + vGroup.alignments.forEach(vRead => drawAlignment(vRead, y)); + ctx.popObject(); + ctx.restore(); } - vGroup.alignments.forEach(vRead => drawAlignment(vRead, y)); - ctx.popObject(); - ctx.restore(); } function renderMismatch(bp: BasePair, y: number) { @@ -235,12 +243,6 @@ function opacityForQuality(quality: number): number { return Math.min(1.0, alpha); } -type NetworkStatus = {numRequests?: number, status?: string}; -type State = { - networkStatus: ?NetworkStatus; -}; - - class PileupTrack extends React.Component { props: VizProps & { source: AlignmentDataSource }; state: State; @@ -269,7 +271,7 @@ class PileupTrack extends React.Component { var statusEl = null, networkStatus = this.state.networkStatus; if (networkStatus) { - var message = this.formatStatus(networkStatus); + var message = formatStatus(networkStatus); statusEl = (
@@ -279,26 +281,30 @@ class PileupTrack extends React.Component { ); } - return ( -
- {statusEl} -
- + var rangeLength = this.props.range.stop - this.props.range.start; + // If range is too large, do not render 'canvas' + if (rangeLength > GA4GHDataSource.MAX_BASE_PAIRS_TO_FETCH) { + return ( +
+
+ Zoom in to see alignments +
+ +
+ ); + } else { + return ( +
+ {statusEl} +
+ +
-
- ); - } - - formatStatus(status: NetworkStatus): string { - if (status.numRequests) { - var pluralS = status.numRequests > 1 ? 's' : ''; - return `issued ${status.numRequests} request${pluralS}`; - } else if (status.status) { - return status.status; + ); } - throw 'invalid'; } + componentDidMount() { this.cache = new PileupCache(this.props.referenceSource, this.props.options.viewAsPairs); this.tiles = new PileupTiledCanvas(this.cache, this.props.options); @@ -362,8 +368,8 @@ class PileupTrack extends React.Component { // Load new reads into the visualization cache. updateReads(range: ContigInterval) { var anyBefore = this.cache.anyGroupsOverlapping(range); - this.props.source.getAlignmentsInRange(range) - .forEach(read => this.cache.addAlignment(read)); + var r = this.props.source.getAlignmentsInRange(range); + r.forEach(read => this.cache.addAlignment(read)); if (!anyBefore && this.cache.anyGroupsOverlapping(range)) { // If these are the first reads to be shown in the visible range, @@ -378,14 +384,15 @@ class PileupTrack extends React.Component { var canvas = this.refs.canvas, width = this.props.width; - // Hold off until height & width are known. - if (width === 0) return; + // Hold off until canvas, height & width are known. + if (width === 0 || typeof canvas == 'undefined') return; // Height can only be computed after the pileup has been updated. - var height = yForRow(this.cache.pileupHeightForRef(this.props.range.contig)); + var height = yForRow(this.tiles.heightForRef(this.props.range.contig)); d3utils.sizeCanvas(canvas, width, height); + // if range is too large, remove all reads var ctx = canvasUtils.getContext(canvas); var dtx = dataCanvas.getDataContext(ctx); this.renderScene(dtx); @@ -458,7 +465,7 @@ class PileupTrack extends React.Component { var vRead = _.find(trackingCtx.hits[0], hit => hit.read); var alert = window.alert || console.log; if (vRead) { - alert(vRead.read.debugString()); + alert(vRead.read.name); } } } diff --git a/src/main/viz/TiledCanvas.js b/src/main/viz/TiledCanvas.js index b668efba..599a5d90 100644 --- a/src/main/viz/TiledCanvas.js +++ b/src/main/viz/TiledCanvas.js @@ -10,6 +10,7 @@ import _ from 'underscore'; import scale from '../scale'; import ContigInterval from '../ContigInterval'; +import {ResolutionCache} from '../ResolutionCache'; import Interval from '../Interval'; import canvasUtils from './canvas-utils'; import dataCanvas from 'data-canvas'; @@ -19,6 +20,7 @@ import d3utils from './d3utils'; type Tile = { pixelsPerBase: number; range: ContigInterval; + originalRange: ContigInterval; buffer: HTMLCanvasElement; }; @@ -47,7 +49,10 @@ class TiledCanvas { var sc = scale.linear().domain([range.start(), range.stop() + 1]).range([0, width]); var ctx = canvasUtils.getContext(tile.buffer); var dtx = dataCanvas.getDataContext(ctx); - this.render(dtx, sc, range); + + + var resolution = ResolutionCache.getResolution(tile.originalRange.interval); + this.render(dtx, sc, range, tile.originalRange, resolution); } // Create (and render) new tiles to fill the gaps. @@ -60,6 +65,7 @@ class TiledCanvas { var newTiles = newIntervals.map(iv => ({ pixelsPerBase, range: new ContigInterval(range.contig, iv.start, iv.stop), + originalRange: range, buffer: document.createElement('canvas') })); @@ -123,7 +129,9 @@ class TiledCanvas { render(dtx: DataCanvasRenderingContext2D, scale: (x: number)=>number, - range: ContigInterval): void { + range: ContigInterval, + originalRange: ?ContigInterval, + resolution: ?number): void { throw 'Not implemented'; } diff --git a/src/main/viz/VariantTrack.js b/src/main/viz/VariantTrack.js index 45ebe2c6..a08d8dd4 100644 --- a/src/main/viz/VariantTrack.js +++ b/src/main/viz/VariantTrack.js @@ -11,34 +11,139 @@ import type {VizProps} from '../VisualizationWrapper'; import type {Scale} from './d3utils'; import React from 'react'; -import ReactDOM from 'react-dom'; +import _ from 'underscore'; import d3utils from './d3utils'; import shallowEquals from 'shallow-equals'; import ContigInterval from '../ContigInterval'; import canvasUtils from './canvas-utils'; +import TiledCanvas from './TiledCanvas'; import dataCanvas from 'data-canvas'; import style from '../style'; +import utils from '../utils'; +import type {State, NetworkStatus} from './pileuputils'; +var MONSTER_REQUEST = 500000; + +class VariantTiledCanvas extends TiledCanvas { + options: Object; + source: VcfDataSource; + + constructor(source: VcfDataSource, options: Object) { + super(); + this.source = source; + this.options = options; + } + + update(newOptions: Object) { + this.options = newOptions; + } + + // TODO: can update to handle overlapping features + heightForRef(ref: string): number { + return style.VARIANT_HEIGHT; + } + + render(ctx: DataCanvasRenderingContext2D, + scale: (x: number)=>number, + range: ContigInterval, + originalRange: ?ContigInterval, + resolution: ?number) { + var relaxedRange = + new ContigInterval(range.contig, range.start() - 1, range.stop() + 1); + + // relaxed range is just for this tile. make sure to get resolution for whole + // viewing area + var vVariants = this.source.getVariantsInRange(relaxedRange, resolution); + renderVariants(ctx, scale, relaxedRange, vVariants); + } +} + +// Draw variants +function renderVariants(ctx: DataCanvasRenderingContext2D, + scale: (num: number) => number, + range: ContigInterval, + variants: Variant[]) { + + ctx.font = `${style.GENE_FONT_SIZE}px ${style.GENE_FONT}`; + ctx.textAlign = 'center'; + + variants.forEach(variant => { + ctx.pushObject(variant); + ctx.fillStyle = style.BASE_COLORS[variant.alt]; + ctx.strokeStyle = style.BASE_COLORS[variant.ref]; + var x = Math.round(scale(variant.position)); + var width = Math.round(scale(variant.end)) - x; + ctx.fillRect(x - 0.2, 0, width, style.VARIANT_HEIGHT); + ctx.strokeRect(x - 0.2, 0, width, style.VARIANT_HEIGHT); + ctx.popObject(); + }); + +} class VariantTrack extends React.Component { props: VizProps & {source: VcfDataSource}; - state: void; // no state + state: State; // no state + tiles: VariantTiledCanvas; constructor(props: Object) { super(props); + this.state = { + networkStatus: null + }; } render(): any { - return ; + var statusEl = null, + networkStatus = this.state.networkStatus; + if (networkStatus) { + statusEl = ( +
+
+ Loading Variants… +
+
+ ); + } + var rangeLength = this.props.range.stop - this.props.range.start; + // If range is too large, do not render 'canvas' + if (rangeLength > MONSTER_REQUEST) { + return ( +
+
+ Zoom in to see variants +
+ +
+ ); + } else { + return ( +
+ {statusEl} +
+ +
+
+ ); + } } componentDidMount() { - this.updateVisualization(); + this.tiles = new VariantTiledCanvas(this.props.source, this.props.options); - this.props.source.on('newdata', () => { + // Visualize new data as it comes in from the network. + this.props.source.on('newdata', (range) => { + this.tiles.invalidateRange(range); this.updateVisualization(); }); + this.props.source.on('networkprogress', e => { + this.setState({networkStatus: e}); + }); + this.props.source.on('networkdone', e => { + this.setState({networkStatus: null}); + }); + + this.updateVisualization(); } getScale(): Scale { @@ -48,58 +153,51 @@ class VariantTrack extends React.Component { componentDidUpdate(prevProps: any, prevState: any) { if (!shallowEquals(prevProps, this.props) || !shallowEquals(prevState, this.state)) { - this.updateVisualization(); + this.tiles.update(this.props.options); + this.tiles.invalidateAll(); + this.updateVisualization(); } } updateVisualization() { - var canvas = ReactDOM.findDOMNode(this), + var canvas = (this.refs.canvas : HTMLCanvasElement), {width, height} = this.props; // Hold off until height & width are known. - if (width === 0) return; - + if (width === 0|| typeof canvas == 'undefined') return; d3utils.sizeCanvas(canvas, width, height); - var ctx = canvasUtils.getContext(canvas); - var dtx = dataCanvas.getDataContext(ctx); - this.renderScene(dtx); + + var ctx = dataCanvas.getDataContext(canvasUtils.getContext(canvas)); + this.renderScene(ctx); } renderScene(ctx: DataCanvasRenderingContext2D) { var range = this.props.range, interval = new ContigInterval(range.contig, range.start, range.stop), - variants = this.props.source.getFeaturesInRange(interval), - scale = this.getScale(), - height = this.props.height, - y = height - style.VARIANT_HEIGHT - 1; + scale = this.getScale(); - ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.reset(); - ctx.save(); - - ctx.fillStyle = style.VARIANT_FILL; - ctx.strokeStyle = style.VARIANT_STROKE; - variants.forEach(variant => { - ctx.pushObject(variant); - var x = Math.round(scale(variant.position)); - var width = Math.round(scale(variant.position + 1)) - 1 - x; - ctx.fillRect(x - 0.5, y - 0.5, width, style.VARIANT_HEIGHT); - ctx.strokeRect(x - 0.5, y - 0.5, width, style.VARIANT_HEIGHT); - ctx.popObject(); - }); - + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + this.tiles.renderToScreen(ctx, interval, scale); ctx.restore(); } handleClick(reactEvent: any) { var ev = reactEvent.nativeEvent, - x = ev.offsetX, - y = ev.offsetY, - canvas = ReactDOM.findDOMNode(this), - ctx = canvasUtils.getContext(canvas), - trackingCtx = new dataCanvas.ClickTrackingContext(ctx, x, y); - this.renderScene(trackingCtx); - var variant = trackingCtx.hit && trackingCtx.hit[0]; + x = ev.offsetX; + + var genomeRange = this.props.range, + // allow some buffering so click isn't so sensitive + range = new ContigInterval(genomeRange.contig, genomeRange.start-1, genomeRange.stop+1), + scale = this.getScale(), + // leave padding of 2px to reduce click specificity + clickStart = Math.floor(scale.invert(x)) - 2, + clickEnd = clickStart + 2, + // If click-tracking gets slow, this range could be narrowed to one + // closer to the click coordinate, rather than the whole visible range. + vVariants = this.props.source.getVariantsInRange(range); + + var variant = _.find(vVariants, f => utils.tupleRangeOverlaps([[f.position], [f.end]], [[clickStart], [clickEnd]])); var alert = window.alert || console.log; if (variant) { alert(JSON.stringify(variant)); diff --git a/src/main/viz/d3utils.js b/src/main/viz/d3utils.js index 9c9b04cf..d0031f59 100644 --- a/src/main/viz/d3utils.js +++ b/src/main/viz/d3utils.js @@ -60,6 +60,8 @@ function formatRange(viewSize: number): {prefix: string, unit: string} { * Sizes a canvas appropriately for this device. */ function sizeCanvas(el: HTMLCanvasElement, width: number, height: number) { + // Hold off until el is defined + if (!el) return; var ratio = window.devicePixelRatio; el.width = width * ratio; el.height = height * ratio; diff --git a/src/main/viz/pileuputils.js b/src/main/viz/pileuputils.js index 22e016b2..2d4c0e79 100644 --- a/src/main/viz/pileuputils.js +++ b/src/main/viz/pileuputils.js @@ -139,7 +139,8 @@ export type OpInfo = { // Breaks the read down into Cigar Ops suitable for display function getOpInfo(read: Alignment, referenceSource: Object): OpInfo { var ops = read.cigarOps; - + // console.log(read); + var range = read.getInterval(), start = range.start(), seq = read.getSequence(), @@ -158,7 +159,11 @@ function getOpInfo(read: Alignment, referenceSource: Object): OpInfo { start: refPos, stop: refPos + op.length - 1 }); + // console.log(seq); + // console.log("seqPos:"+seqPos); + // console.log("op.length:"+op.length); var mSeq = seq.slice(seqPos, seqPos + op.length); + // console.log(mSeq) mismatches = mismatches.concat(findMismatches(ref, mSeq, refPos, scores)); } @@ -201,9 +206,26 @@ function getOpInfo(read: Alignment, referenceSource: Object): OpInfo { }; } +// State information. Used in viz +export type NetworkStatus = {numRequests?: number, status?: string}; +export type State = { + networkStatus: ?NetworkStatus; +}; + +function formatStatus(status: NetworkStatus): string { + if (status.numRequests) { + var pluralS = status.numRequests > 1 ? 's' : ''; + return `issued ${status.numRequests} request${pluralS}`; + } else if (status.status) { + return status.status; + } + throw 'invalid'; +} + module.exports = { pileup, addToPileup, getOpInfo, + formatStatus, CigarOp }; diff --git a/src/test/ADAMAlignment-test.js b/src/test/ADAMAlignment-test.js new file mode 100644 index 00000000..9edda342 --- /dev/null +++ b/src/test/ADAMAlignment-test.js @@ -0,0 +1,70 @@ +/** @flow */ +'use strict'; + +import {expect} from 'chai'; + +import GA4GHAlignment from '../main/GA4GHAlignment'; +import RemoteFile from '../main/RemoteFile'; +import Bam from '../main/data/bam'; + +describe('ADAMAlignment', function() { + var sampleAlignments = []; + + before(function() { + return new RemoteFile('/test-data/chr17.1-250.json').getAllString().then(data => { + sampleAlignments = JSON.parse(data).alignments; + }); + }); + + it('should read the sample alignments', function() { + expect(sampleAlignments).to.have.length(14); + }); + + it('should provide basic accessors', function() { + var a = new GA4GHAlignment(sampleAlignments[0]); + expect(a.name).to.equal('r000'); + expect(a.getSequence()).to.equal('ATTTAGCTAC'); + expect(a.getQualityScores()).to.deep.equal([32,32,32,32,32,32,32,32,32,32]); + expect(a.getStrand()).to.equal('+'); + expect(a.getInterval().toString()).to.equal('chr17:4-13'); // 0-based + expect(a.cigarOps).to.deep.equal([ + {op: 'M', length: 10} + ]); + expect(a.getMateProperties()).to.deep.equal({ + ref: 'chr17', + pos: 79, + strand: '+' + }); + }); + + it('should match SamRead', function() { + var bam = new Bam(new RemoteFile('/test-data/chr17.1-250.bam')); + return bam.readAll().then(({alignments: samReads}) => { + // This is a workaround. See https://github.com/ga4gh/server/issues/488 + samReads.splice(-1, 1); + + expect(sampleAlignments.length).to.equal(samReads.length); + for (var i = 0; i < sampleAlignments.length; i++) { + var ga4gh = new GA4GHAlignment(sampleAlignments[i]), + bam = samReads[i]; + expect(ga4gh.getSequence()).to.equal(bam.getSequence()); + // See https://github.com/ga4gh/server/issues/491 + // expect(ga4gh.getStrand()).to.equal(bam.getStrand()); + // For the if statement, see https://github.com/ga4gh/server/issues/492 + var quality = ga4gh.getQualityScores(); + if (quality.length) { + expect(quality).to.deep.equal(bam.getQualityScores()); + } + expect(ga4gh.cigarOps).to.deep.equal(bam.cigarOps); + // After ga4gh#491, change this to a .deep.equal on getMateProperties() + var ga4ghMate = ga4gh.getMateProperties(), + bamMate = bam.getMateProperties(); + expect(!!ga4ghMate).to.equal(!!bamMate); + if (ga4ghMate && bamMate) { + expect(ga4ghMate.ref).to.equal(bamMate.ref); + expect(ga4ghMate.pos).to.equal(bamMate.pos); + } + } + }); + }); +}); diff --git a/src/test/FakeAlignment.js b/src/test/FakeAlignment.js index de67c4a4..3a6470a2 100644 --- a/src/test/FakeAlignment.js +++ b/src/test/FakeAlignment.js @@ -6,6 +6,8 @@ import type {Alignment, CigarOp, MateProperties, Strand} from '../main/Alignment'; import type ContigInterval from '../main/ContigInterval'; +import type {SequenceRecord} from '../main/data/Sequence'; + var numAlignments = 1; class FakeAlignment /* implements Alignment */ { @@ -63,7 +65,7 @@ var fakeSource = { rangeChanged: dieFn, getRange: function(): any { return {}; }, getRangeAsString: function(): string { return ''; }, - contigList: function(): string[] { return []; }, + contigList: function(): SequenceRecord[] { return []; }, normalizeRange: function() { }, on: dieFn, off: dieFn, diff --git a/src/test/Interval-test.js b/src/test/Interval-test.js index 00652c10..f754fcf1 100644 --- a/src/test/Interval-test.js +++ b/src/test/Interval-test.js @@ -87,6 +87,11 @@ describe('Interval', function() { new Interval(10, 20) ])).to.be.true; + expect(iv.isCoveredBy([ + new Interval(0, 5), + new Interval(9, 21) + ])).to.be.true; + expect(iv.isCoveredBy([ new Interval(0, 10), new Interval(5, 15), @@ -167,5 +172,6 @@ describe('Interval', function() { '[20, 29]', '[50, 79]' ]); + }); }); diff --git a/src/test/RemoteRequest-test.js b/src/test/RemoteRequest-test.js index b24dec91..8ea7b715 100644 --- a/src/test/RemoteRequest-test.js +++ b/src/test/RemoteRequest-test.js @@ -5,8 +5,9 @@ import {expect} from 'chai'; import sinon from 'sinon'; -import RemoteRequest from '../main/RemoteRequest'; +import {RemoteRequest} from '../main/RemoteRequest'; import RemoteFile from '../main/RemoteFile'; +import ContigInterval from '../main/ContigInterval'; describe('RemoteRequest', function() { var server: any = null, response; @@ -14,11 +15,17 @@ describe('RemoteRequest', function() { var contig = 'chr17'; var start = 10; var stop = 20; + var interval = new ContigInterval(contig, start, stop); + var basePairsPerFetch = 1000; before(function () { return new RemoteFile('/test-data/chr17.1-250.json').getAllString().then(data => { response = data; server = sinon.fakeServer.create(); + var endpoint = '/test/chr17?start=10&end=20'; + server.respondWith('GET', endpoint, + [200, { "Content-Type": "application/json" }, response]); + }); }); @@ -27,41 +34,16 @@ describe('RemoteRequest', function() { }); it('should fetch json from a server', function(done) { - var remoteRequest = new RemoteRequest(url); - var endpoint = remoteRequest.getEndpointFromContig(contig, start, stop); - server.respondWith('GET', endpoint, - [200, { "Content-Type": "application/json" }, response]); - - var promisedData = remoteRequest.get(contig, start, stop); - promisedData.then(obj => { - var ret = obj.alignments; + var remoteRequest = new RemoteRequest(url, basePairsPerFetch); + var promisedData = remoteRequest.get(interval); + promisedData.then(e => { + var ret = e.response.alignments; expect(remoteRequest.numNetworkRequests).to.equal(1); + expect(e.status).to.equal(200); expect(ret.length).to.equal(14); done(); }); server.respond(); }); - - it('should cache data after server response', function(done) { - var remoteRequest = new RemoteRequest(url); - // verify cache is cleared for testing - remoteRequest.cache.clear(); - var endpoint = remoteRequest.getEndpointFromContig(contig, start, stop); - server.respondWith('GET', endpoint, - [200, { "Content-Type": "application/json" }, response]); - - var promisedData = remoteRequest.get(contig, start, stop); - promisedData.then(obj => { - var promisedData2 = remoteRequest.get(contig, start, stop); - promisedData2.then(obj2 => { - var ret = obj2.alignments; - expect(remoteRequest.numNetworkRequests).to.equal(1); - expect(ret.length).to.equal(14); - done(); - }); - }); - - server.respond(); - }); }); diff --git a/src/test/ResolutionCache-test.js b/src/test/ResolutionCache-test.js new file mode 100644 index 00000000..07a94d2a --- /dev/null +++ b/src/test/ResolutionCache-test.js @@ -0,0 +1,88 @@ +/** @flow */ +'use strict'; + +import {expect} from 'chai'; + +import ContigInterval from '../main/ContigInterval'; +import {ResolutionCache} from '../main/ResolutionCache'; + +describe('ResolutionCache', function() { + + // Type used for testing cache + type Position = { + contig: string; + position: number; + } + + var smRange = new ContigInterval("chrM", 0, 100); + var smRange2 = new ContigInterval("chrM", 100, 200); + var smRange3 = new ContigInterval("chrM", 300, 400); + + var bigRange = new ContigInterval("chrM", 0, 1000000); + var data: Position[] = [{contig:'chrM',position:2}, + {contig:'chrM',position:3}, + {contig:'chrM',position:6}, + {contig:'chrM',position:107}]; + + function filterFunction(range: ContigInterval, p: Position): boolean { + return range.chrContainsLocus(p.contig, p.position); + } + + function keyFunction(p: Position): string { + return `${p.contig}:${p.position}`; + } + + function createCache(): ResolutionCache { + var cache = new ResolutionCache(filterFunction, keyFunction); + return cache; + } + + + it('should create cache', function() { + var cache: ResolutionCache = createCache(); + expect(cache == {}); + }); + + it('should put and get data in range', function() { + var cache: ResolutionCache = createCache(); + cache.coverRange(smRange); + data.forEach(p => cache.put(p)); + var d = cache.get(smRange); + expect(d.length == 3); + }); + + it('should cover ranges after put', function() { + var cache: ResolutionCache = createCache(); + cache.coverRange(smRange); + data.forEach(p => cache.put(p)); + var covered = cache.coversRange(smRange); + expect(covered === true); + }); + + it('should clear the cache', function() { + var cache: ResolutionCache = createCache(); + cache.coverRange(smRange); + data.forEach(p => cache.put(p)); + cache.clear(); + expect(cache.cache == {}); + expect(cache.coveredRanges == []); + }); + + it('should return false when finer resolution was not yet loaded', function() { + var cache: ResolutionCache = createCache(); + cache.coverRange(bigRange); + var covered = cache.coversRange(smRange); + expect(covered === false); + }); + + it('should coalesce the covered ranges', function() { + var cache: ResolutionCache = createCache(); + cache.coverRange(smRange); + cache.coverRange(smRange3); + expect(cache.coveredRanges.length == 2); + + cache.coverRange(smRange2); + expect(cache.coveredRanges.length == 2); + }); + +}); diff --git a/src/test/sources/CoverageDataSource-test.js b/src/test/sources/CoverageDataSource-test.js new file mode 100644 index 00000000..20bf5b7d --- /dev/null +++ b/src/test/sources/CoverageDataSource-test.js @@ -0,0 +1,99 @@ +/** @flow */ +'use strict'; + +import {expect} from 'chai'; + +import sinon from 'sinon'; + +import ContigInterval from '../../main/ContigInterval'; +import CoverageDataSource from '../../main/sources/CoverageDataSource'; +import RemoteFile from '../../main/RemoteFile'; + +describe('CoverageDataSource', function() { + var server: any = null, response; + + before(function () { + return new RemoteFile('/test-data/chr17-coverage.json').getAllString().then(data => { + response = data; + server = sinon.fakeServer.create(); + server.respondWith('GET', '/coverage/17?start=1&end=1000&binning=1',[200, { "Content-Type": "application/json" }, response]); + }); + }); + + after(function () { + server.restore(); + }); + + it('should fetch coverage points from a server', function(done) { + + var source = CoverageDataSource.create({ + url: '/coverage' + }); + + var requestInterval = new ContigInterval('17', 10, 30); + expect(source.getCoverageInRange(requestInterval)) + .to.deep.equal([]); + + source.on('newdata', () => { + var coverage = source.getCoverageInRange(requestInterval); + expect(coverage).to.have.length(12); + done(); + }); + + source.rangeChanged({contig: '17', start: 10, stop: 30}); + server.respond(); + }); + + it('should cache coverage after first call', function(done) { + + var source = CoverageDataSource.create({ + url: '/coverage' + }); + var requestCount = 0; + var requestInterval = new ContigInterval('17', 10, 20); + expect(source.getCoverageInRange(requestInterval)) + .to.deep.equal([]); + + var progress = []; + source.on('networkprogress', e => { progress.push(e); }); + source.on('networkdone', e => { progress.push('done'); }); + source.on('newdata', () => { + requestCount += 1; + expect(requestCount == 1); + done(); + }); + + source.rangeChanged({contig: '17', start: 1, stop: 30}); + source.rangeChanged({contig: '17', start: 2, stop: 8}); + + server.respond(); + + }); + + it('should bin coverage over large regions', function(done) { + + var source = CoverageDataSource.create({ + url: '/coverage' + }); + var requestCount = 0; + var requestInterval = new ContigInterval('17', 10, 20); + expect(source.getCoverageInRange(requestInterval)) + .to.deep.equal([]); + + var progress = []; + source.on('networkprogress', e => { progress.push(e); }); + source.on('networkdone', e => { progress.push('done'); }); + source.on('newdata', () => { + requestCount += 1; + expect(requestCount == 1); + done(); + }); + + source.rangeChanged({contig: '17', start: 1, stop: 30}); + source.rangeChanged({contig: '17', start: 2, stop: 8}); + + server.respond(); + + }); + +}); diff --git a/src/test/sources/FeatureDataSource-test.js b/src/test/sources/FeatureDataSource-test.js new file mode 100644 index 00000000..8021864b --- /dev/null +++ b/src/test/sources/FeatureDataSource-test.js @@ -0,0 +1,81 @@ +/* @flow */ +'use strict'; + +import {expect} from 'chai'; + +import sinon from 'sinon'; + +import ContigInterval from '../../main/ContigInterval'; +import FeatureDataSource from '../../main/sources/FeatureDataSource'; +import RemoteFile from '../../main/RemoteFile'; + +describe('FeatureDataSource', function() { + var server: any = null, response; + + before(function () { + return new RemoteFile('/test-data/features-chrM-1000-1200.json').getAllString().then(data => { + response = data; + server = sinon.fakeServer.create(); + server.respondWith('GET', '/features/chrM?start=1&end=10000&binning=1', [200, { "Content-Type": "application/json" }, response]); + server.respondWith('GET', '/features/chrM?start=1&end=1000', [200, { "Content-Type": "application/json" }, '']); + }); + }); + + after(function () { + server.restore(); + }); + + function getTestSource() { + var source = FeatureDataSource.create({ + url: '/features' + }); + return source; + } + + it('should extract features in a range', function(done) { + var source = getTestSource(); + + // No features fetched initially + var range = new ContigInterval('chrM', 1000, 1200); + var emptyFeatures = source.getFeaturesInRange(range); + expect(emptyFeatures).to.deep.equal([]); + + // Fetching that one gene should cache its entire block. + source.on('newdata', () => { + var features = source.getFeaturesInRange(range).sort((a, b) => { + return a.start - b.start; + }); + expect(features).to.have.length(2); + + var feature = features[0]; + expect(feature.start).to.equal(1011); + expect(feature.contig).to.equal('chrM'); + done(); + }); + source.rangeChanged({ + contig: range.contig, + start: range.start(), + stop: range.stop() + }); + server.respond(); + }); + + it('should not fail when no feature data is available', function(done) { + var source = getTestSource(); + + var range = new ContigInterval('chrM', 1, 100); + + // Fetching that one gene should cache its entire block. + source.on('newdata', () => { + var features = source.getFeaturesInRange(range); + expect(features).to.have.length(0); + done(); + }); + source.rangeChanged({ + contig: range.contig, + start: range.start(), + stop: range.stop() + }); + server.respond(); + }); +}); diff --git a/src/test/sources/GA4GHDataSource-test.js b/src/test/sources/GA4GHDataSource-test.js index e6fd6467..5d85a63b 100644 --- a/src/test/sources/GA4GHDataSource-test.js +++ b/src/test/sources/GA4GHDataSource-test.js @@ -8,7 +8,7 @@ import sinon from 'sinon'; import ContigInterval from '../../main/ContigInterval'; import GA4GHDataSource from '../../main/sources/GA4GHDataSource'; import RemoteFile from '../../main/RemoteFile'; - + describe('GA4GHDataSource', function() { var server: any = null, response; @@ -24,12 +24,13 @@ describe('GA4GHDataSource', function() { }); it('should fetch alignments from a server', function(done) { - server.respondWith('POST', '/v0.5.1/reads/search', + // ALYSSA: TODO: should move back to POST as in original API + server.respondWith('GET', '/v0.5.1/reads/search/chr17?start=1&end=1000', [200, { "Content-Type": "application/json" }, response]); var source = GA4GHDataSource.create({ - endpoint: '/v0.5.1', - readGroupId: 'some-group-set:some-read-group', + endpoint: '/v0.5.1/reads', + readGroupId: 'search', killChr: false }); diff --git a/src/test/sources/ReferenceDataSource-test.js b/src/test/sources/ReferenceDataSource-test.js new file mode 100644 index 00000000..7aad52a8 --- /dev/null +++ b/src/test/sources/ReferenceDataSource-test.js @@ -0,0 +1,157 @@ +/** @flow */ +'use strict'; + +import {expect} from 'chai'; + +import sinon from 'sinon'; +import _ from 'underscore'; +import ReferenceDataSource from '../../main/sources/ReferenceDataSource'; +import RemoteFile from '../../main/RemoteFile'; + +describe('ReferenceDataSource', function() { + var server: any = null, response; + var source: any = null; + + beforeEach(() => { + // define ReferenceDataSource + source = ReferenceDataSource.create({ + url: '/reference', + contigList: [{ + name:"chrM", + length: 1000 + },{ + name:"22", + length: 1000 + }] + }); + }); + + afterEach(() => { + source = null; + }); + + before(function () { + return new RemoteFile('/test-data/reference-chrM-0-1000.json').getAllString().then(data => { + response = data; + server = sinon.fakeServer.create(); + server.respondWith('GET', '/reference/chrM?start=0&end=10000',[200, { "Content-Type": "application/json" }, response]); + server.respondWith('GET', '/reference/chrM?start=0&end=10',[200, { "Content-Type": "application/json" }, response]); + server.respondWith('GET', '/reference/22?start=0&end=10000',[200, { "Content-Type": "application/json" }, response]); + }); + }); + + after(function () { + server.restore(); + }); + + it('should fetch contigs', function() { + var contigs = source.contigList(); + contigs = _.map(contigs, contig => contig.name); + expect(contigs).to.deep.equal(['chrM','22']); + }); + + it('should normalize range', function() { + var range = {contig: 'chrM', start: 0, stop: 3}; + source.normalizeRange(range).then(normalized => { + expect(normalized.to.deep.equal(range)); + }).done(); + }); + + it('should fetch base pairs', function(done) { + var range = {contig: 'chrM', start: 0, stop: 3}; + + // Before data has been fetched, all base pairs are null. + var obj = source.getRange(range); + expect(obj).to.deep.equal({ + 'chrM:0': null, + 'chrM:1': null, + 'chrM:2': null, + 'chrM:3': null + }); + var str = source.getRangeAsString(range); + expect(str).to.equal('....'); + + source.on('newdata', () => { + console.log("new data"); + obj = source.getRange(range); + console.log("range", obj); + expect(obj).to.deep.equal({ + 'chrM:0': 'N', + 'chrM:1': 'G', + 'chrM:2': 'T', + 'chrM:3': 'T' + }); + str = source.getRangeAsString(range); + expect(str).to.equal('NGTT'); + + done(); + }); + source.rangeChanged(range); + server.respond(); + done(); + }); + + + it('should fetch nearby base pairs', function(done) { + + source.on('newdata', () => { + expect(source.getRange({contig: 'chrM', start: 0, stop: 14})) + .to.deep.equal({ + 'chrM:0': 'N', + 'chrM:1': 'G', + 'chrM:2': 'T', + 'chrM:3': 'T', + 'chrM:4': 'A', // start of actual request + 'chrM:5': 'A', + 'chrM:6': 'T', + 'chrM:7': 'G', + 'chrM:8': 'T', + 'chrM:9': 'A', // end of actual requuest + 'chrM:10': 'G', + 'chrM:11': 'C', + 'chrM:12': 'T', + 'chrM:13': 'T', + 'chrM:14': 'A' + }); + done(); + }); + source.rangeChanged({contig: 'chrM', start: 4, stop: 9}); + server.respond(); + }); + + it('should add chr', function(done) { + var range = {contig: '22', start: 0, stop: 3}; + + source.on('newdata', () => { + expect(source.getRange(range)).to.deep.equal({ + '22:0': 'N', + '22:1': 'G', + '22:2': 'T', + '22:3': 'T' + }); + expect(source.getRangeAsString(range)).to.equal('NGTT'); + done(); + }); + source.rangeChanged(range); + server.respond(); + }); + + it('should only report newly-fetched ranges', function(done) { + ReferenceDataSource.testBasePairsToFetch(10); + var initRange = {contig: 'chrM', start: 5, stop: 8}, + secondRange = {contig: 'chrM', start: 8, stop: 15}; + source.once('newdata', newRange => { + expect(newRange.toString()).to.equal('chrM:0-10'); // expanded range + + source.once('newdata', newRange => { + // This expanded range excludes previously-fetched data. + expect(newRange.toString()).to.equal('chrM:11-20'); + done(); + }); + source.rangeChanged(secondRange); + server.respond(); + }); + source.rangeChanged(initRange); + server.respond(); + }); +}); diff --git a/src/test/sources/VariantDataSource-test.js b/src/test/sources/VariantDataSource-test.js new file mode 100644 index 00000000..27a7b740 --- /dev/null +++ b/src/test/sources/VariantDataSource-test.js @@ -0,0 +1,76 @@ +/* @flow */ +'use strict'; + + +import {expect} from 'chai'; + +import sinon from 'sinon'; + +import VariantDataSource from '../../main/sources/VariantDataSource'; +import ContigInterval from '../../main/ContigInterval'; +import RemoteFile from '../../main/RemoteFile'; + +describe('VariantDataSource', function() { + var server: any = null, response; + + before(function () { + return new RemoteFile('/test-data/variants-chrM-0-100.json').getAllString().then(data => { + response = data; + server = sinon.fakeServer.create(); + server.respondWith('GET', '/variants/chrM?start=1&end=10000&binning=1',[200, { "Content-Type": "application/json" }, response]); + server.respondWith('GET', '/variants/chrM?start=1000&end=2000',[200, { "Content-Type": "application/json" }, '']); + }); + }); + + after(function () { + server.restore(); + }); + + function getTestSource() { + var source = VariantDataSource.create({ + url: '/variants', + samples: ["sample1", "sample2", "sample3"] + }); + return source; + } + it('should extract features in a range', function(done) { + var source = getTestSource(); + var range = new ContigInterval('chrM', 0, 50); + // No variants are cached yet. + var variants = source.getVariantsInRange(range); + expect(variants).to.deep.equal([]); + + source.on('newdata', () => { + var variants = source.getVariantsInRange(range); + expect(variants).to.have.length(3); + expect(variants[1].contig).to.equal('chrM'); + expect(variants[1].position).to.equal(20); + expect(variants[1].ref).to.equal('G'); + expect(variants[1].alt).to.equal('T'); + done(); + }); + source.rangeChanged({ + contig: range.contig, + start: range.start(), + stop: range.stop() + }); + server.respond(); + }); + + it('should not fail when no variants are availble', function(done) { + var source = getTestSource(); + var range = new ContigInterval('chrM', 1050, 1150); + + source.on('newdata', () => { + var variants = source.getVariantsInRange(range); + expect(variants).to.deep.equal([]); + done(); + }); + source.rangeChanged({ + contig: range.contig, + start: range.start(), + stop: range.stop() + }); + server.respond(); + }); +}); diff --git a/src/test/sources/VcfDataSource-test.js b/src/test/sources/VcfDataSource-test.js index ea6b0559..2251e066 100644 --- a/src/test/sources/VcfDataSource-test.js +++ b/src/test/sources/VcfDataSource-test.js @@ -19,11 +19,11 @@ describe('VcfDataSource', function() { var range = new ContigInterval('20', 63799, 69094); // No variants are cached yet. - var variants = source.getFeaturesInRange(range); + var variants = source.getVariantsInRange(range); expect(variants).to.deep.equal([]); source.on('newdata', () => { - var variants = source.getFeaturesInRange(range); + var variants = source.getVariantsInRange(range); expect(variants).to.have.length(6); expect(variants[0].contig).to.equal('20'); expect(variants[0].position).to.equal(63799); diff --git a/src/test/viz/CoverageTrack-test.js b/src/test/viz/CoverageTrack-test.js index 17537fc1..4c50aff8 100644 --- a/src/test/viz/CoverageTrack-test.js +++ b/src/test/viz/CoverageTrack-test.js @@ -6,22 +6,35 @@ */ 'use strict'; -import type SamRead from '../../main/data/SamRead'; - import {expect} from 'chai'; +import sinon from 'sinon'; + +import RemoteFile from '../../main/RemoteFile'; import pileup from '../../main/pileup'; -import TwoBit from '../../main/data/TwoBit'; -import TwoBitDataSource from '../../main/sources/TwoBitDataSource'; -import MappedRemoteFile from '../MappedRemoteFile'; import dataCanvas from 'data-canvas'; import {waitFor} from '../async'; describe('CoverageTrack', function() { var testDiv = document.getElementById('testdiv'); - var range = {contig: '17', start: 7500730, stop: 7500790}; + var range = {contig: '17', start: 0, stop: 100}; var p; + var server: any = null, response; + + before(function () { + return new RemoteFile('/test-data/chr17-coverage.json').getAllString().then(data => { + response = data; + server = sinon.fakeServer.create(); + server.respondWith('GET', '/coverage/17?start=1&end=1000&binning=1',[200, { "Content-Type": "application/json" }, response]); + }); + }); + + after(function () { + server.restore(); + }); + + beforeEach(() => { dataCanvas.RecordingContext.recordAll(); // A fixed width container results in predictable x-positions for mismatches. @@ -30,17 +43,18 @@ describe('CoverageTrack', function() { range: range, tracks: [ { - data: referenceSource, viz: pileup.viz.genome(), + data: pileup.formats.twoBit({ + url: '/test-data/test.2bit' + }), isReference: true }, { viz: pileup.viz.coverage(), - data: pileup.formats.bam({ - url: '/test-data/synth3.normal.17.7500000-7515000.bam', - indexUrl: '/test-data/synth3.normal.17.7500000-7515000.bam.bai' + data: pileup.formats.coverage({ + url: '/coverage', }), - cssClass: 'tumor-coverage', + cssClass: 'coverage', name: 'Coverage' } ] @@ -55,21 +69,12 @@ describe('CoverageTrack', function() { testDiv.style.width = ''; }); - var twoBitFile = new MappedRemoteFile( - '/test-data/hg19.2bit.mapped', - [[0, 16383], [691179834, 691183928], [694008946, 694011447]]); - var referenceSource = TwoBitDataSource.createFromTwoBitFile(new TwoBit(twoBitFile)); - var {drawnObjectsWith, callsOf} = dataCanvas.RecordingContext; var findCoverageBins = () => { return drawnObjectsWith(testDiv, '.coverage', b => b.count); }; - var findMismatchBins = ():Array => { - return drawnObjectsWith(testDiv, '.coverage', b => b.base); - }; - var findCoverageLabels = () => { return drawnObjectsWith(testDiv, '.coverage', l => l.type == 'label'); }; @@ -78,7 +83,6 @@ describe('CoverageTrack', function() { // Check whether the coverage bins are loaded yet return testDiv.querySelector('canvas') && findCoverageBins().length > 1 && - findMismatchBins().length > 0 && findCoverageLabels().length > 1; }; @@ -89,18 +93,6 @@ describe('CoverageTrack', function() { }); }); - it('should show mismatch information', function() { - return waitFor(hasCoverage, 2000).then(() => { - var visibleMismatches = findMismatchBins() - .filter(bin => bin.position >= range.start && bin.position <= range.stop); - expect(visibleMismatches).to.deep.equal( - [{position: 7500765, count: 23, base: 'C'}, - {position: 7500765, count: 22, base: 'T'}]); - // TODO: IGV shows counts of 20 and 20 at this locus. Whither the five reads? - // `samtools view` reports the full 45 reads at 17:7500765 - }); - }); - it('should create correct labels for coverage', function() { return waitFor(hasCoverage, 2000).then(() => { // These are the objects being used to draw labels @@ -108,7 +100,7 @@ describe('CoverageTrack', function() { expect(labelTexts[0].label).to.equal('0X'); expect(labelTexts[labelTexts.length-1].label).to.equal('50X'); - // Now let's test if they are actually being put on the screen + // Now let's test if they are actually being put on the screens var texts = callsOf(testDiv, '.coverage', 'fillText'); expect(texts.map(t => t[1])).to.deep.equal(['0X', '25X', '50X']); }); diff --git a/src/test/viz/FeatureTrack-test.js b/src/test/viz/FeatureTrack-test.js new file mode 100644 index 00000000..d7804e32 --- /dev/null +++ b/src/test/viz/FeatureTrack-test.js @@ -0,0 +1,92 @@ +/** + * This tests whether feature information is being shown/drawn correctly + * in the track. + * + * @flow + */ +'use strict'; + +import {expect} from 'chai'; + +import sinon from 'sinon'; + +import RemoteFile from '../../main/RemoteFile'; +import pileup from '../../main/pileup'; +import dataCanvas from 'data-canvas'; +import {waitFor} from '../async'; + +describe('FeatureTrack', function() { + var testDiv = document.getElementById('testdiv'); + var range = {contig: 'chrM', start: 900, stop: 1500}; + var server: any = null, response; + + beforeEach(() => { + testDiv.style.width = '800px'; + dataCanvas.RecordingContext.recordAll(); + }); + + afterEach(() => { + dataCanvas.RecordingContext.reset(); + // avoid pollution between tests. + server.restore(); + }); + + before(function () { + return new RemoteFile('/test-data/features-chrM-1000-1200.json').getAllString().then(data => { + server = sinon.fakeServer.create(); + response = data; + }); + }); + + after(function () { + server.restore(); + }); + + var drawnObjects = dataCanvas.RecordingContext.drawnObjects; + + function ready() { + return testDiv.querySelector('canvas') && + drawnObjects(testDiv, '.features').length > 0; + } + + it('should render features', function() { + server.respondWith('GET', '/features/chrM?start=0&end=10000', [200, { "Content-Type": "application/json" }, ""]); + var p = pileup.create(testDiv, { + range: range, + tracks: [ + { + viz: pileup.viz.genome(), + data: pileup.formats.twoBit({ + url: '/test-data/test.2bit' + }), + isReference: true + }, + { + + data: pileup.formats.features({ + url: '/features', + }), + viz: pileup.viz.features() + }, + { + data: pileup.formats.bigBed({ + url: '/test-data/ensembl.chr17.bb' + }), + viz: pileup.viz.genes(), + } + ] + }); + + return waitFor(ready, 2000) + .then(() => { + var features = drawnObjects(testDiv, '.features'); + var ids = ["4ee7469a-b468-429b-a109-07a484817037", "e105ce29-a840-4fc6-819f-a9aac5166163"]; + expect(features).to.have.length(2); + expect(features.map(f => f.start)).to.deep.equal( + [1107, 1011]); + expect(features.map(g => g.id)).to.deep.equal(ids); + p.destroy(); + }); + }); + +}); diff --git a/src/test/viz/GenomeTrack-test.js b/src/test/viz/GenomeTrack-test.js index 0af362b7..f68f5002 100644 --- a/src/test/viz/GenomeTrack-test.js +++ b/src/test/viz/GenomeTrack-test.js @@ -54,7 +54,7 @@ describe('GenomeTrack', function() { it('should tolerate non-chr ranges', function() { var p = pileup.create(testDiv, { - range: {contig: '17', start: 7500730, stop: 7500790}, + range: {contig: 'chr17', start: 7500730, stop: 7500790}, tracks: [ { data: referenceSource, @@ -95,7 +95,7 @@ describe('GenomeTrack', function() { it('should zoom from huge zoom out', function() { var p = pileup.create(testDiv, { - range: { contig: '17', start: 0, stop: 114529884 }, + range: { contig: 'chr17', start: 0, stop: 114529884 }, tracks: [{ data: referenceSource, viz: pileup.viz.genome(), @@ -116,7 +116,7 @@ describe('GenomeTrack', function() { return waitFor(referenceTrackLoaded, 2000).then(() => { //in global view we shouldn't see reference track expect(hasReference()).to.be.false; - p.setRange({contig: '17', start: 7500725, stop: 7500775}); + p.setRange({contig: 'chr17', start: 7500725, stop: 7500775}); }).delay(300).then(() => { //after zoom in we should see reference track expect(hasReference()).to.be.true; @@ -127,7 +127,7 @@ describe('GenomeTrack', function() { it('should zoom in and out', function() { var p = pileup.create(testDiv, { - range: {contig: '17', start: 7500725, stop: 7500775}, + range: {contig: 'chr17', start: 7500725, stop: 7500775}, tracks: [ { data: referenceSource, @@ -170,7 +170,7 @@ describe('GenomeTrack', function() { it('should accept user-entered locations', function() { var p = pileup.create(testDiv, { - range: {contig: '17', start: 7500725, stop: 7500775}, + range: {contig: 'chr17', start: 7500725, stop: 7500775}, tracks: [ { data: referenceSource, diff --git a/src/test/viz/GenotypeTrack-test.js b/src/test/viz/GenotypeTrack-test.js new file mode 100644 index 00000000..8ffb65b0 --- /dev/null +++ b/src/test/viz/GenotypeTrack-test.js @@ -0,0 +1,81 @@ +/** + * This tests that the Controls and reference track render correctly, even when + * an externally-set range uses a different chromosome naming system (e.g. '17' + * vs 'chr17'). See https://github.com/hammerlab/pileup.js/issues/146 + * @flow + */ + +'use strict'; + +import {expect} from 'chai'; + +import sinon from 'sinon'; + +import pileup from '../../main/pileup'; +import dataCanvas from 'data-canvas'; +import {waitFor} from '../async'; +import RemoteFile from '../../main/RemoteFile'; + +describe('GenotypeTrack', function() { + var server: any = null, response; + + before(function () { + return new RemoteFile('/test-data/genotypes-17.json').getAllString().then(data => { + response = data; + server = sinon.fakeServer.create(); + server.respondWith('GET', '/genotypes/17?start=1&end=1000',[200, { "Content-Type": "application/json" }, response]); + }); + }); + + var testDiv = document.getElementById('testdiv'); + + beforeEach(() => { + testDiv.style.width = '800px'; + dataCanvas.RecordingContext.recordAll(); + }); + + afterEach(() => { + dataCanvas.RecordingContext.reset(); + // avoid pollution between tests. + testDiv.innerHTML = ''; + }); + var drawnObjects = dataCanvas.RecordingContext.drawnObjects; + + function ready() { + return testDiv.querySelector('canvas') && + drawnObjects(testDiv, '.genotypes').length > 0; + } + + it('should render genotypes', function() { + var p = pileup.create(testDiv, { + // range: {contig: 'chrM', start: 0, stop: 30}, + range: {contig: '17', start: 9386380, stop: 9537420}, + tracks: [ + { + viz: pileup.viz.genome(), + data: pileup.formats.twoBit({ + url: '/test-data/test.2bit' + }), + isReference: true + }, + { + data: pileup.formats.variants({ + url: '/test-data/genotypes-17.json' + }), + viz: pileup.viz.genotypes(), + } + ] + }); + + return waitFor(ready, 2000) + .then(() => { + var genotypes = drawnObjects(testDiv, '.genotypes'); + expect(genotypes).to.have.length(3); + expect(genotypes.map(g => g.variant.position)).to.deep.equal( + [10, 20, 30]); + + p.destroy(); + }); + }); + +}); diff --git a/src/test/viz/PileupCoverageTrack-test.js b/src/test/viz/PileupCoverageTrack-test.js new file mode 100644 index 00000000..47da689a --- /dev/null +++ b/src/test/viz/PileupCoverageTrack-test.js @@ -0,0 +1,117 @@ +/** + * This tests whether coverage information is being shown/drawn correctly + * in the track. The alignment information comes from the test BAM files. + * + * @flow + */ +'use strict'; + +import type SamRead from '../../main/data/SamRead'; + +import {expect} from 'chai'; + +import pileup from '../../main/pileup'; +import TwoBit from '../../main/data/TwoBit'; +import TwoBitDataSource from '../../main/sources/TwoBitDataSource'; +import MappedRemoteFile from '../MappedRemoteFile'; +import dataCanvas from 'data-canvas'; +import {waitFor} from '../async'; + +describe('PileupCoverageTrack', function() { + var testDiv = document.getElementById('testdiv'); + var range = {contig: '17', start: 7500730, stop: 7500790}; + var p; + + beforeEach(() => { + dataCanvas.RecordingContext.recordAll(); + // A fixed width container results in predictable x-positions for mismatches. + testDiv.style.width = '800px'; + p = pileup.create(testDiv, { + range: range, + tracks: [ + { + data: referenceSource, + viz: pileup.viz.genome(), + isReference: true + }, + { + viz: pileup.viz.pileupcoverage(), + data: pileup.formats.bam({ + url: '/test-data/synth3.normal.17.7500000-7515000.bam', + indexUrl: '/test-data/synth3.normal.17.7500000-7515000.bam.bai' + }), + cssClass: 'tumor-coverage', + name: 'Coverage' + } + ] + }); + }); + + afterEach(() => { + dataCanvas.RecordingContext.reset(); + if (p) p.destroy(); + // avoid pollution between tests. + testDiv.innerHTML = ''; + testDiv.style.width = ''; + }); + + var twoBitFile = new MappedRemoteFile( + '/test-data/hg19.2bit.mapped', + [[0, 16383], [691179834, 691183928], [694008946, 694011447]]); + var referenceSource = TwoBitDataSource.createFromTwoBitFile(new TwoBit(twoBitFile)); + + var {drawnObjectsWith, callsOf} = dataCanvas.RecordingContext; + + var findCoverageBins = () => { + return drawnObjectsWith(testDiv, '.coverage', b => b.count); + }; + + var findMismatchBins = ():Array => { + return drawnObjectsWith(testDiv, '.coverage', b => b.base); + }; + + var findCoverageLabels = () => { + return drawnObjectsWith(testDiv, '.coverage', l => l.type == 'label'); + }; + + var hasCoverage = () => { + // Check whether the coverage bins are loaded yet + return testDiv.querySelector('canvas') && + findCoverageBins().length > 1 && + findMismatchBins().length > 0 && + findCoverageLabels().length > 1; + }; + + it('should create coverage information for all bases shown in the view', function() { + return waitFor(hasCoverage, 2000).then(() => { + var bins = findCoverageBins(); + expect(bins).to.have.length.at.least(range.stop - range.start + 1); + }); + }); + + it('should show mismatch information', function() { + return waitFor(hasCoverage, 2000).then(() => { + var visibleMismatches = findMismatchBins() + .filter(bin => bin.position >= range.start && bin.position <= range.stop); + expect(visibleMismatches).to.deep.equal( + [{position: 7500765, count: 23, base: 'C'}, + {position: 7500765, count: 22, base: 'T'}]); + // TODO: IGV shows counts of 20 and 20 at this locus. Whither the five reads? + // `samtools view` reports the full 45 reads at 17:7500765 + }); + }); + + it('should create correct labels for coverage', function() { + return waitFor(hasCoverage, 2000).then(() => { + // These are the objects being used to draw labels + var labelTexts = findCoverageLabels(); + expect(labelTexts[0].label).to.equal('0X'); + expect(labelTexts[labelTexts.length-1].label).to.equal('50X'); + + // Now let's test if they are actually being put on the screen + var texts = callsOf(testDiv, '.coverage', 'fillText'); + expect(texts.map(t => t[1])).to.deep.equal(['0X', '25X', '50X']); + }); + }); + +}); diff --git a/style/pileup.css b/style/pileup.css index 46db9167..64110d54 100644 --- a/style/pileup.css +++ b/style/pileup.css @@ -13,18 +13,27 @@ .pileup-root > .track { display: flex; flex-direction: row; + margin-bottom: 5px; } .pileup-root text, .track-label { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; } +/* hack to hide track label for genotypes */ +.genotypes > .track-label { + display: none; +} .track-label { flex: 0 0 100px; /* fixed-width track labels */ text-align: right; + overflow: hidden; + text-overflow: ellipsis; font-size: 0.9em; position: relative; /* make this an offset parent for positioning the label. */ } .track-label > span { padding-right: 5px; + overflow: hidden; + text-overflow: ellipsis; } /* bottom-justify these track labels */ .track.reference .track-label > span, @@ -42,6 +51,7 @@ } .track-content > div { position: absolute; /* Gets the child of the flex-item to fill height 100% */ + width: 100%; } .track-content canvas { display: block; @@ -171,7 +181,7 @@ } /* pileup track */ -.pileup-root > .pileup { +.pileup-root > .pileup, .pileup-root > .genotypes { flex: 1; /* stretch to fill remaining space */ } .pileup .alignment .match { @@ -190,16 +200,25 @@ stroke: black; stroke-width: 2; } -.pileup .network-status { +.network-status { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + position: relative; + top: 20px; +} +.network-status-small { height: 100%; width: 100%; display: flex; align-items: center; justify-content: center; position: relative; - top: 30px; + top: 9px; } -.pileup .network-status-message { +.network-status-message { padding: 4px 8px; width: auto; background: #eee; @@ -209,6 +228,16 @@ position: absolute; text-align: center; } +.network-status-message-small { + padding: 0px 8px; + width: auto; + background: #eee; + border-radius: 3px; + border: 1px solid #ccc; + font-size: 10px; + position: absolute; + text-align: center; +} .pileup .mate-connector { stroke: #c8c8c8; /* matches IGV */ @@ -281,3 +310,6 @@ dominant-baseline: central; text-anchor: middle; } +.center { + text-align: center; +} diff --git a/test-data/chr17-coverage.json b/test-data/chr17-coverage.json new file mode 100644 index 00000000..51f62f1b --- /dev/null +++ b/test-data/chr17-coverage.json @@ -0,0 +1,61 @@ +[{ + "contig": "17", + "start": 10, + "end": 11, + "count": 50 +}, { + "contig": "17", + "start": 11, + "end": 12, + "count": 52 +}, { + "contig": "17", + "start": 12, + "end": 13, + "count": 54 +}, { + "contig": "17", + "start": 13, + "end": 14, + "count": 56 +}, { + "contig": "17", + "start": 14, + "end": 15, + "count": 58 +}, { + "contig": "17", + "start": 15, + "end": 16, + "count": 58 +}, { + "contig": "17", + "start": 16, + "end": 17, + "count": 58 +}, { + "contig": "17", + "start": 17, + "end": 18, + "count": 58 +}, { + "contig": "17", + "start": 18, + "end": 19, + "count": 68 +}, { + "contig": "17", + "start": 19, + "end": 20, + "count": 72 +}, { + "contig": "17", + "start": 20, + "end": 21, + "count": 75 +}, { + "contig": "17", + "start": 21, + "end": 22, + "count": 62 +}] diff --git a/test-data/features-chrM-1000-1200.json b/test-data/features-chrM-1000-1200.json new file mode 100644 index 00000000..8cb7551c --- /dev/null +++ b/test-data/features-chrM-1000-1200.json @@ -0,0 +1,15 @@ +[{ + "id": "4ee7469a-b468-429b-a109-07a484817037", + "featureType": "peak", + "contig": "chrM", + "start": 1107, + "stop": 1200, + "score": 1000 +}, { + "id": "e105ce29-a840-4fc6-819f-a9aac5166163", + "featureType": "peak", + "contig": "chrM", + "start": 1011, + "stop": 1012, + "score": 10 +}] diff --git a/test-data/genes-chrM-0-30000.json b/test-data/genes-chrM-0-30000.json new file mode 100644 index 00000000..13bf39e9 --- /dev/null +++ b/test-data/genes-chrM-0-30000.json @@ -0,0 +1,855 @@ +[{ + "position": { + "referenceName": "chrM", + "start": 14362, + "end": 24886, + "orientation": {} + }, + "id": "ENST00000541675", + "strand": false, + "codingRegion": { + "start": 14362, + "end": 24886 + }, + "exons": [{ + "exonId": "ENSE00002254515", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 24733, + "end": 24886, + "orientation": {} + } + }, { + "exonId": "ENSE00002303227", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 18267, + "end": 18369, + "orientation": {} + } + }, { + "exonId": "ENSE00003638984", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17914, + "end": 18061, + "orientation": {} + } + }, { + "exonId": "ENSE00003629019", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17605, + "end": 17742, + "orientation": {} + } + }, { + "exonId": "ENSE00002285713", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17497, + "end": 17504, + "orientation": {} + } + }, { + "exonId": "ENSE00001656010", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17232, + "end": 17364, + "orientation": {} + } + }, { + "exonId": "ENSE00001760358", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16853, + "end": 17055, + "orientation": {} + } + }, { + "exonId": "ENSE00003497546", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14969, + "end": 15038, + "orientation": {} + } + }, { + "exonId": "ENSE00003511598", + "transcriptId": "ENST00000541675", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14362, + "end": 14829, + "orientation": {} + } + }], + "geneId": "ENSG00000227232", + "name": "ENST00000541675" +}, { + "position": { + "referenceName": "chrM", + "start": 14403, + "end": 29570, + "orientation": {} + }, + "id": "ENST00000488147", + "strand": false, + "codingRegion": { + "start": 14403, + "end": 29570 + }, + "exons": [{ + "exonId": "ENSE00001890219", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 29533, + "end": 29570, + "orientation": {} + } + }, { + "exonId": "ENSE00003507205", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 24737, + "end": 24891, + "orientation": {} + } + }, { + "exonId": "ENSE00003477500", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 18267, + "end": 18366, + "orientation": {} + } + }, { + "exonId": "ENSE00003565697", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17914, + "end": 18061, + "orientation": {} + } + }, { + "exonId": "ENSE00003475637", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17605, + "end": 17742, + "orientation": {} + } + }, { + "exonId": "ENSE00003502542", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17232, + "end": 17368, + "orientation": {} + } + }, { + "exonId": "ENSE00003553898", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16857, + "end": 17055, + "orientation": {} + } + }, { + "exonId": "ENSE00003621279", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16606, + "end": 16765, + "orientation": {} + } + }, { + "exonId": "ENSE00002030414", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15795, + "end": 15947, + "orientation": {} + } + }, { + "exonId": "ENSE00001935574", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15004, + "end": 15038, + "orientation": {} + } + }, { + "exonId": "ENSE00001843071", + "transcriptId": "ENST00000488147", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14403, + "end": 14501, + "orientation": {} + } + }], + "geneId": "ENSG00000227232", + "name": "ENST00000488147" +}, { + "position": { + "referenceName": "chrM", + "start": 14362, + "end": 29370, + "orientation": {} + }, + "id": "ENST00000423562", + "strand": false, + "codingRegion": { + "start": 14362, + "end": 29370 + }, + "exons": [{ + "exonId": "ENSE00001718035", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 29320, + "end": 29370, + "orientation": {} + } + }, { + "exonId": "ENSE00003603734", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 24737, + "end": 24891, + "orientation": {} + } + }, { + "exonId": "ENSE00003513603", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17914, + "end": 18061, + "orientation": {} + } + }, { + "exonId": "ENSE00003565315", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17605, + "end": 17742, + "orientation": {} + } + }, { + "exonId": "ENSE00003685767", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17232, + "end": 17368, + "orientation": {} + } + }, { + "exonId": "ENSE00003553898", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16857, + "end": 17055, + "orientation": {} + } + }, { + "exonId": "ENSE00003621279", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16606, + "end": 16765, + "orientation": {} + } + }, { + "exonId": "ENSE00002030414", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15795, + "end": 15947, + "orientation": {} + } + }, { + "exonId": "ENSE00003591210", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14969, + "end": 15038, + "orientation": {} + } + }, { + "exonId": "ENSE00003693168", + "transcriptId": "ENST00000423562", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14362, + "end": 14829, + "orientation": {} + } + }], + "geneId": "ENSG00000227232", + "name": "ENST00000423562" +}, { + "position": { + "referenceName": "chrM", + "start": 14362, + "end": 29370, + "orientation": {} + }, + "id": "ENST00000438504", + "strand": false, + "codingRegion": { + "start": 14362, + "end": 29370 + }, + "exons": [{ + "exonId": "ENSE00001718035", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 29320, + "end": 29370, + "orientation": {} + } + }, { + "exonId": "ENSE00003624050", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 24737, + "end": 24891, + "orientation": {} + } + }, { + "exonId": "ENSE00001642865", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 18267, + "end": 18379, + "orientation": {} + } + }, { + "exonId": "ENSE00003638984", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17914, + "end": 18061, + "orientation": {} + } + }, { + "exonId": "ENSE00001699689", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17601, + "end": 17742, + "orientation": {} + } + }, { + "exonId": "ENSE00001656010", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17232, + "end": 17364, + "orientation": {} + } + }, { + "exonId": "ENSE00001760358", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16853, + "end": 17055, + "orientation": {} + } + }, { + "exonId": "ENSE00003618297", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16606, + "end": 16765, + "orientation": {} + } + }, { + "exonId": "ENSE00001375216", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15903, + "end": 15947, + "orientation": {} + } + }, { + "exonId": "ENSE00001388009", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15795, + "end": 15901, + "orientation": {} + } + }, { + "exonId": "ENSE00003497546", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14969, + "end": 15038, + "orientation": {} + } + }, { + "exonId": "ENSE00003511598", + "transcriptId": "ENST00000438504", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14362, + "end": 14829, + "orientation": {} + } + }], + "geneId": "ENSG00000227232", + "name": "ENST00000438504" +}, { + "position": { + "referenceName": "chrM", + "start": 14410, + "end": 29806, + "orientation": {} + }, + "id": "ENST00000538476", + "strand": false, + "codingRegion": { + "start": 14410, + "end": 29806 + }, + "exons": [{ + "exonId": "ENSE00001378845", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 29533, + "end": 29806, + "orientation": {} + } + }, { + "exonId": "ENSE00002317443", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 24736, + "end": 24891, + "orientation": {} + } + }, { + "exonId": "ENSE00003682243", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 18267, + "end": 18366, + "orientation": {} + } + }, { + "exonId": "ENSE00003638984", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17914, + "end": 18061, + "orientation": {} + } + }, { + "exonId": "ENSE00001699689", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17601, + "end": 17742, + "orientation": {} + } + }, { + "exonId": "ENSE00001656010", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 17232, + "end": 17364, + "orientation": {} + } + }, { + "exonId": "ENSE00003632482", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16857, + "end": 17055, + "orientation": {} + } + }, { + "exonId": "ENSE00002275850", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16747, + "end": 16765, + "orientation": {} + } + }, { + "exonId": "ENSE00002241734", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 16606, + "end": 16745, + "orientation": {} + } + }, { + "exonId": "ENSE00001375216", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15903, + "end": 15947, + "orientation": {} + } + }, { + "exonId": "ENSE00001388009", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 15795, + "end": 15901, + "orientation": {} + } + }, { + "exonId": "ENSE00002215305", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14999, + "end": 15038, + "orientation": {} + } + }, { + "exonId": "ENSE00002295553", + "transcriptId": "ENST00000538476", + "strand": false, + "region": { + "referenceName": "chrM", + "start": 14410, + "end": 14502, + "orientation": {} + } + }], + "geneId": "ENSG00000227232", + "name": "ENST00000538476" +}, { + "position": { + "referenceName": "chrM", + "start": 11873, + "end": 14409, + "orientation": {} + }, + "id": "ENST00000518655", + "strand": true, + "codingRegion": { + "start": 11873, + "end": 14409 + }, + "exons": [{ + "exonId": "ENSE00002269724", + "transcriptId": "ENST00000518655", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 11873, + "end": 12227, + "orientation": {} + } + }, { + "exonId": "ENSE00002270865", + "transcriptId": "ENST00000518655", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12594, + "end": 12721, + "orientation": {} + } + }, { + "exonId": "ENSE00002216795", + "transcriptId": "ENST00000518655", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 13402, + "end": 13655, + "orientation": {} + } + }, { + "exonId": "ENSE00002303382", + "transcriptId": "ENST00000518655", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 13660, + "end": 14409, + "orientation": {} + } + }], + "geneId": "ENSG00000223972", + "name": "ENST00000518655" +}, { + "position": { + "referenceName": "chrM", + "start": 11871, + "end": 14412, + "orientation": {} + }, + "id": "ENST00000515242", + "strand": true, + "codingRegion": { + "start": 11871, + "end": 14412 + }, + "exons": [{ + "exonId": "ENSE00002234632", + "transcriptId": "ENST00000515242", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 11871, + "end": 12227, + "orientation": {} + } + }, { + "exonId": "ENSE00003608237", + "transcriptId": "ENST00000515242", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12612, + "end": 12721, + "orientation": {} + } + }, { + "exonId": "ENSE00002306041", + "transcriptId": "ENST00000515242", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 13224, + "end": 14412, + "orientation": {} + } + }], + "geneId": "ENSG00000223972", + "name": "ENST00000515242" +}, { + "position": { + "referenceName": "chrM", + "start": 11868, + "end": 14409, + "orientation": {} + }, + "id": "ENST00000456328", + "strand": true, + "codingRegion": { + "start": 11868, + "end": 14409 + }, + "exons": [{ + "exonId": "ENSE00002234944", + "transcriptId": "ENST00000456328", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 11868, + "end": 12227, + "orientation": {} + } + }, { + "exonId": "ENSE00003582793", + "transcriptId": "ENST00000456328", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12612, + "end": 12721, + "orientation": {} + } + }, { + "exonId": "ENSE00002312635", + "transcriptId": "ENST00000456328", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 13220, + "end": 14409, + "orientation": {} + } + }], + "geneId": "ENSG00000223972", + "name": "ENST00000456328" +}, { + "position": { + "referenceName": "chrM", + "start": 12009, + "end": 13670, + "orientation": {} + }, + "id": "ENST00000450305", + "strand": true, + "codingRegion": { + "start": 12009, + "end": 13670 + }, + "exons": [{ + "exonId": "ENSE00001948541", + "transcriptId": "ENST00000450305", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12009, + "end": 12057, + "orientation": {} + } + }, { + "exonId": "ENSE00001671638", + "transcriptId": "ENST00000450305", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12178, + "end": 12227, + "orientation": {} + } + }, { + "exonId": "ENSE00001758273", + "transcriptId": "ENST00000450305", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12612, + "end": 12697, + "orientation": {} + } + }, { + "exonId": "ENSE00001799933", + "transcriptId": "ENST00000450305", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 12974, + "end": 13052, + "orientation": {} + } + }, { + "exonId": "ENSE00001746346", + "transcriptId": "ENST00000450305", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 13220, + "end": 13374, + "orientation": {} + } + }, { + "exonId": "ENSE00001863096", + "transcriptId": "ENST00000450305", + "strand": true, + "region": { + "referenceName": "chrM", + "start": 13452, + "end": 13670, + "orientation": {} + } + }], + "geneId": "ENSG00000223972", + "name": "ENST00000450305" +}] diff --git a/test-data/genotypes-17.json b/test-data/genotypes-17.json new file mode 100644 index 00000000..b0112c7e --- /dev/null +++ b/test-data/genotypes-17.json @@ -0,0 +1,25 @@ +[{ + "sampleIds": ["sample1", "sample2", "sample3"], + "variant": { + "contig": "17", + "position": 9386380, + "ref": "C", + "alt": "G" + } +}, { + "sampleIds": ["sample1", "sample2", "sample3"], + "variant": { + "contig": "17", + "position": 9386390, + "ref": "G", + "alt": "T" + } +}, { + "sampleIds": ["sample1", "sample2", "sample3"], + "variant": { + "contig": "17", + "position": 9386400, + "ref": "A", + "alt": "C" + } +}] diff --git a/test-data/genotypes-chrM-0-100.json b/test-data/genotypes-chrM-0-100.json new file mode 100644 index 00000000..f39bd291 --- /dev/null +++ b/test-data/genotypes-chrM-0-100.json @@ -0,0 +1,25 @@ +[{ + "sampleIds": ["sample1", "sample2", "sample3"], + "variant": { + "contig": "chrM", + "position": 10, + "ref": "C", + "alt": "G" + } +}, { + "sampleIds": ["sample1", "sample2", "sample3"], + "variant": { + "contig": "chrM", + "position": 20, + "ref": "G", + "alt": "T" + } +}, { + "sampleIds": ["sample1", "sample2", "sample3"], + "variant": { + "contig": "chrM", + "position": 30, + "ref": "A", + "alt": "C" + } +}] diff --git a/test-data/reference-chrM-0-1000.json b/test-data/reference-chrM-0-1000.json new file mode 100644 index 00000000..99d342cf --- /dev/null +++ b/test-data/reference-chrM-0-1000.json @@ -0,0 +1 @@ +"NGTTAATGTAGCTTAATAACAAAGCAAAGCACTGAAAATGCTTAGATGATAATTGTATCCCATAAACACAAAGGTTTGGTCCTGGCCTTATAATTAATTAGAGGTAAAATTACACATGCAAACCTCCATAGACCGGTGTAAAATCCCTTAAACATTTACTTAAAATTTAAGGAGAGGGTATCAAGCACATTAAAATAGCTTAAGACACCTTGCCTAGCCACACCCCCACGGGACTCAGCAGTGATAAATATTAAGCAATAAACGAAAGTTTGACTAAGTTATACCTCTTAGGGTTGGTAAATTTCGTGCCAGCCACCGCGGTCATACGATTAACCCAAACTAATTATCTTCGGCGTAAAACGTGTCAACTATAAATAAATAAATAGAATTAAAATCCAACTTATATGTGAAAATTCATTGTTAGGACCTAAACTCAATAACGAAAGTAATTCTAGTCATTTATAATACACGACAGCTAAGACCCAAACTGGGATTAGATACCCCACTATGCTTAGCCATAAACCTAAATAATTAAATTTAACAAAACTATTTGCCAGAGAACTACTAGCCATAGCTTAAAACTCAAAGGACTTGGCGGTACTTTATATCCATCTAGAGGAGCCTGTTCTATAATCGATAAACCCCGCTCTACCTCACCATCTCTTGCTAATTCAGCCTATATACCGCCATCTTCAGCAAACCCTAAAAAGGTATTAAAGTAAGCAAAAGAATCAAACATAAAAACGTTAGGTCAAGGTGTAGCCAATGAAATGGGAAGAAATGGGCTACATTTTCTTATAAAAGAACATTACTATACCCTTTATGAAACTAAAGGACTAAGGAGGATTTAGTAGTAAATTAAGAATAGAGAGCTTAATTGAATTGAGCAATGAAGTACGCACACACCGCCCGTCACCCTCCTCAAATTAAATTAAACTTAACATAATTAATTTCTAGACATCCGTTTATGAGAGGAGATAAGTCGTAACAAGGTAAGCATACTGGAAAGTGTGCTTGGAATAATCATAGTGTAGCTTAATATTAAAGCATCTGGCCTACACCCAGAAGATTTCATGACCAATGAACACTCTGAACTAATCCTAGCCCTAGCCCTACACAAATATAATTATACTATTATATAAATCAAAACATTTATCCTACTAAAAGTATTGGAGAAAGAAATTCGTACATCTAGGAGCTATAGAACTAGTACCGCAAGGGAAAGATGAAAGACTAATTAAAAGTAAGAACAAGCAAAGATTAAACCTTGTACCTTTTGCATAATGAACTAACTAGAAAACTTCTAACTAAAAGAATTACAGCTAGAAACCCCGAAACCAAACGAGCTACCTAAAAACAATTTTATGAATCAACTCGTCTATGTGGCAAAATAGTGAGAAGATTTTTAGGTAGAGGTGAAAAGCCTAACGAGCTTGGTGATAGCTGGTTACCCAAAAAATGAATTTAAGTTCAATTTTAAACTTGCTAAAAAAACAACAAAATCAAAAAGTAAGTTTAGATTATAGCCAAAAGAGGGACAGCTCTTCTGGAACGGAAAAAACCTTTAATAGTGAATAATTAACAAAACAGCTTTTAACCATTGTAGGCCTAAAAGCAGCCACCAATAAAGAAAGCGTTCAAGCTCAACATAAAATTTCAATTAATTCCATAATTTACACCAACTTCCTAAACTTAAAATTGGGTTAATCTATAACTTTATAGATGCAACACTGTTAGTATGAGTAACAAGAATTCCAATTCTCCAGGCATACGCGTATAACAACTCGGATAACCATTGTTAGTTAATCAGACTATAGGCAATAATCACACTATAAATAATCCACCTATAACTTCTCTGTTAACCCAACACCGGAATGCCTAAAGGAAAGATCCAAAAAGATAAAAGGAACTCGGCAAACAAGAACCCCGCCTGTTTACCAAAAACATCACCTCTAGCATTACAAGTATTAGAGGCACTGCCTGCCCAGTGACTAAAGTTTAACGGCCGCGGTATCCTGACCGTGCAAAGGTAGCATAATCACTTGTTCCTTAATTAGGGACTAGCATGAACGGCTAAACGAGGGTCCAACTGTCTCTTATCTTTAATCAGTGAAATTGACCTTTCAGTGAAGAGGCTGAAATATAATAATAAGACGAGAAGACCCTATGGAGCTTAAATTATATAACTTATCTATTTAATTTATTAAACCTAATGGCCCAAAAACTATAGTATAAGTTTGAAATTTCGGTTGGGGTGACCTCGGAGAATAAAAAATCCTCCGAATGATTATAACCTAGACTTACAAGTCAAAGTAAAATCAACATATCTTATTGACCCAGATATATTTTGATCAACGGACCAAGTTACCCTAGGGATAACAGCGCAATCCTATTTAAGAGTTCATATCGACAATTAGGGTTTACGACCTCGATGTTGGATCAGGACATCCCAATGGTGTAGAAGCTATTAATGGTTCGTTTGTTCAACGATTAAAGTCCTACGTGATCTGAGTTCAGACCGGAGCAATCCAGGTCGGTTTCTATCTATTTACGATTTCTCCCAGTACGAAAGGACAAGAGAAATAGAGCCACCTTACAAATAAGCGCTCTCAACTTAATTTATGAATAAAATCTAAATAAAATATATACGTACACCCTCTAACCTAGAGAAGGTTATTAGGGTGGCAGAGCCAGGAAATTGCGTAAGACTTAAAACCTTGTTCCCAGAGGTTCAAATCCTCTCCCTAATAGTGTTCTTTATTAATATCCTAACACTCCTCGTCCCCATTCTAATCGCCATAGCCTTCCTAACATTAGTAGAACGCAAAATCTTAGGGTACATACAACTACGAAAAGGCCCTAACATTGTTGGTCCATACGGCATTTTACAACCATTTGCAGACGCCATAAAATTATTTATAAAAGAACCAATACGCCCTTTAACAACCTCTATATCCTTATTTATTATTGCACCTACCCTATCACTCACACTAGCATTAAGTCTATGAGTTCCCCTACCAATACCACACCCATTAATTAATTTAAACCTAGGGATTTTATTTATTTTAGCAACATCTAGCCTATCAGTTTACTCCATTCTATGATCAGGATGAGCCTCAAACTCCAAATACTCACTATTCGGAGCTTTACGAGCCGTAGCCCAAACAATTTCATATGAAGTAACCATAGCTATTATCCTTTTATCAGTTCTATTAATAAATGGATCCTACTCTCTACAAACACTTATTACAACCCAAGAACACATATGATTACTTCTGCCAGCCTGACCCATAGCCATAATATGATTTATCTCAACCCTAGCAGAAACAAACCGGGCCCCCTTCGACCTGACAGAAGGAGAATCAGAATTAGTATCAGGGTTTAACGTAGAATACGCAGCCGGCCCATTCGCGTTATTCTTTATAGCAGAGTACACTAACATTATTCTAATAAACGCCCTAACAACTATTATCTTCCTAGGACCCCTATACTATATCAATTTACCAGAACTCTACTCAACTAACTTCATAATAGAAGCTCTACTACTATCATCAACATTCCTATGGATCCGAGCATCTTATCCACGCTTCCGTTACGATCAACTTATACATCTTCTATGAAAAAACTTTCTACCCCTAACACTAGCATTATGTATGTGACATATTTCTTTACCAATTTTTACAGCGGGAGTACCACCATACATATAGAAATATGTCTGATAAAAGAATTACTTTGATAGAGTAAATTATAGAGGTTCAAGCCCTCTTATTTCTAGGACAATAGGAATTGAACCTACACTTAAGAATTCAAAATTCTCCGTGCTACCTAAACACCTTATCCTAATAGTAAGGTCAGCTAATTAAGCTATCGGGCCCATACCCCGAAAACGTTGGTTTAAATCCTTCCCGTACTAATAAATCCTATCACCCTTGCCATCATCTACTTCACAATCTTCTTAGGTCCTGTAATCACAATATCCAGCACCAACCTAATACTAATATGAGTAGGCCTGGAATTCAGCCTACTAGCAATTATCCCCATACTAATCAACAAAAAAAACCCACGATCAACTGAAGCAGCAACAAAATACTTCGTCACACAAGCAACAGCCTCAATAATTATCCTCCTGGCCATCGTACTCAACTATAAACAACTAGGAACATGAATATTTCAACAACAAACAAACGGTCTTATCCTTAACATAACATTAATAGCCCTATCCATAAAACTAGGCCTCGCCCCATTCCACTTCTGATTACCAGAAGTAACTCAAGGGATCCCACTGCACATAGGACTTATTCTTCTTACATGACAAAAAATTGCTCCCCTATCAATTTTAATTCAAATTTACCCGCTACTCAACTCTACTATCATTTTAATACTAGCAATTACTTCTATTTTCATAGGGGCATGAGGAGGACTTAACCAAACACAAATACGAAAAATTATAGCCTATTCATCAATTGCCCACATAGGATGAATATTAGCAATTCTTCCTTACAACCCATCCCTCACTCTACTCAACCTCATAATCTATATTATTCTTACAGCCCCTATATTCATAGCACTTATACTAAATAACTCTATAACCATCAACTCAATCTCACTTCTATGAAATAAAACTCCAGCAATACTAACTATAATCTCACTGATATTACTATCCCTAGGAGGCCTTCCACCACTAACAGGATTCTTACCAAAATGAATTATCATCACAGAACTTATAAAAAACAACTGTCTAATTATAGCAACACTCATAGCAATAATAGCTCTACTAAACCTATTCTTTTATACTCGCCTAATTTATTCCACTTCACTAACAATATTTCCAACCAACAATAACTCAAAAATAATAACTCACCAAACAAAAACTAAACCCAACCTAATATTTTCCACCCTAGCTATCATAAGCACAATAACCCTACCCCTAGCCCCCCAACTAATTACCTAGAAGTTTAGGATATACTAGTCCGCGAGCCTTCAAAGCCCTAAGAAAACACACAAGTTTAACTTCTGATAAGGACTGTAAGACTTCATCCTACATCTATTGAATGCAAATCAATTGCTTTAATTAAGCTAAGACCTCAACTAGATTGGCAGGAATTAAACCTACGAAAATTTAGTTAACAGCTAAATACCCTATTACTGGCTTCAATCTACTTCTACCGCCGAAAAAAAAAAATGGCGGTAGAAGTCTTAGTAGAGATTTCTCTACACCTTCGAATTTGCAATTCGACATGAATATCACCTTAAGACCTCTGGTAAAAAGAGGATTTAAACCTCTGTGTTTAGATTTACAGTCTAATGCTTACTCAGCCATTTTACCTATGTTCATTAATCGTTGATTATTCTCAACCAATCACAAAGATATCGGAACCCTCTATCTACTATTCGGAGCCTGAGCGGGAATAGTGGGTACTGCACTAAGTATTTTAATTCGAGCAGAATTAGGTCAACCAGGTGCACTTTTAGGAGATGACCAAATTTACAATGTTATCGTAACTGCCCATGCTTTTGTTATAATTTTCTTCATAGTAATACCAATAATAATTGGAGGCTTTGGAAACTGACTTGTCCCACTAATAATCGGAGCCCCAGATATAGCATTCCCACGAATAAATAATATAAGTTTTTGACTCCTACCACCATCATTTCTCCTTCTCCTAGCATCATCAATAGTAGAAGCAGGAGCAGGAACAGGATGAACAGTCTACCCACCTCTAGCCGGAAATCTAGCCCATGCAGGAGCATCAGTAGACCTAACAATTTTCTCCCTTCATTTAGCTGGAGTGTCATCTATTTTAGGTGCAATTAATTTTATTACCACTATTATCAACATGAAACCCCCAGCCATAACACAGTATCAAACTCCACTATTTGTCTGATCCGTACTTATTACAGCCGTACTGCTCCTATTATCACTACCAGTGCTAGCCGCAGGCATTACTATACTACTAACAGACCGCAACCTAAACACAACTTTCTTTGATCCCGCTGGAGGAGGGGACCCAATTCTCTACCAGCATCTGTTCTGATTCTTTGGGCACCCAGAAGTTTATATTCTTATCCTCCCAGGATTTGGAATTATTTCACATGTAGTTACTTACTACTCCGGAAAAAAAGAACCTTTCGGCTATATAGGAATAGTATGAGCAATAATGTCTATTGGCTTTCTAGGCTTTATTGTATGAGCCCACCACATATTCACAGTAGGATTAGATGTAGACACACGAGCTTACTTTACATCAGCCACTATAATTATCGCAATTCCTACCGGTGTCAAAGTATTTAGCTGACTTGCAACCCTACACGGAGGTAATATTAAATGATCTCCAGCTATACTATGAGCCTTAGGCTTTATTTTCTTATTTACAGTTGGTGGTCTAACCGGAATTGTTTTATCCAACTCATCCCTTGACATCGTGCTTCACGATACATACTATGTAGTAGCCCATTTCCACTATGTTCTATCAATGGGAGCAGTGTTTGCTATCATAGCAGGATTTGTTCACTGATTCCCATTATTTTCAGGCTTCACCCTAGATGACACATGAGCAAAAGCCCACTTCGCCATCATATTCGTAGGAGTAAACATAACATTCTTCCCTCAACATTTCCTGGGCCTTTCAGGAATACCACGACGCTACTCAGACTACCCAGATGCTTACACCACATGAAACACTGTCTCTTCTATAGGATCATTTATTTCACTAACAGCTGTTCTCATCATGATCTTTATAATTTGAGAGGCCTTTGCTTCAAAACGAGAAGTAATATCAGTATCGTATGCTTCAACAAATTTAGAATGACTTCATGGCTGCCCTCCACCATATCACACATTCGAGGAACCAACCTATGTAAAAGTAAAATAAGAAAGGAAGGAATCGAACCCCCTAAAATTGGTTTCAAGCCAATCTCATATCCTATATGTCTTTCTCAATAAGATATTAGTAAAATCAATTACATAACTTTGTCAAAGTTAAATTATAGATCAATAATCTATATATCTTATATGGCCTACCCATTCCAACTTGGTCTACAAGACGCCACATCCCCTATTATAGAAGAGCTAATAAATTTCCATGATCACACACTAATAATTGTTTTCCTAATTAGCTCCTTAGTCCTCTATATCATCTCGCTAATATTAACAACAAAACTAACACATACAAGCACAATAGATGCACAAGAAGTTGAAACCATTTGAACTATTCTACCAGCTGTAATCCTTATCATAATTGCTCTCCCCTCTCTACGCATTCTATATATAATAGACGAAATCAACAACCCCGTATTAACCGTTAAAACCATAGGGCACCAATGATACTGAAGCTACGAATATACTGACTATGAAGACCTATGCTTTGATTCATATATAATCCCAACAAACGACCTAAAACCTGGTGAACTACGACTGCTAGAAGTTGATAACCGAGTCGTTCTGCCAATAGAACTTCCAATCCGTATATTAATTTCATCTGAAGACGTCCTCCACTCATGAGCAGTCCCCTCCCTAGGACTTAAAACTGATGCCATCCCAGGCCGACTAAATCAAGCAACAGTAACATCAAACCGACCAGGGTTATTCTATGGCCAATGCTCTGAAATTTGTGGATCTAACCATAGCTTTATGCCCATTGTCCTAGAAATGGTTCCACTAAAATATTTCGAAAACTGATCTGCTTCAATAATTTAATTTCACTATGAAGCTAAGAGCGTTAACCTTTTAAGTTAAAGTTAGAGACCTTAAAATCTCCATAGTGATATGCCACAACTAGATACATCAACATGATTTATCACAATTATCTCATCAATAATTACCCTATTTATCTTATTTCAACTAAAAGTCTCATCACAAACATTCCCACTGGCACCTTCACCAAAATCACTAACAACCATAAAAGTAAAAACCCCTTGAGAATTAAAATGAACGAAAATCTATTTGCCTCATTCATTACCCCAACAATAATAGGATTCCCAATCGTTGTAGCCATCATTATATTTCCTTCAATCCTATTCCCATCCTCAAAACGCCTAATCAACAACCGTCTCCATTCTTTCCAACACTGACTAGTTAAACTTATTATCAAACAAATAATGCTAATCCACACACCAAAAGGACGAACATGAACCCTAATAATTGTTTCCCTAATCATATTTATTGGATCAACAAATCTCCTAGGCCTTTTACCACATACATTTACACCTACTACCCAACTATCCATAAATCTAAGTATAGCCATTCCACTATGAGCTGGAGCCGTAATTACAGGCTTCCGACACAAACTAAAAAGCTCACTTGCCCACTTCCTTCCACAAGGAACTCCAATTTCACTAATTCCAATACTTATTATTATTGAAACAATTAGCCTATTTATTCAACCAATGGCATTAGCAGTCCGGCTTACAGCTAACATTACTGCAGGACACTTATTAATACACCTAATCGGAGGAGCTACTCTAGTATTAATAAATATTAGCCCACCAACAGCTACCATTACATTTATTATTTTACTTCTACTCACAATTCTAGAATTTGCAGTAGCATTAATTCAAGCCTACGTATTCACCCTCCTAGTAAGCCTATATCTACATGATAATACATAATGACCCACCAAACTCATGCATATCACATAGTTAATCCAAGTCCATGACCATTAACTGGAGCCTTTTCAGCCCTCCTTCTAACATCAGGTCTAGTAATATGATTTCACTATAATTCAATTACACTATTAACCCTTGGCCTACTCACCAATATCCTCACAATATATCAATGATGACGAGACGTAATTCGTGAAGGAACCTACCAAGGCCACCACACTCCTATTGTACAAAAAGGACTACGATATGGTATAATTCTATTCATCGTCTCGGAAGTATTTTTCTTTGCAGGATTCTTCTGAGCGTTCTATCATTCTAGCCTCGTACCAACACATGATCTAGGAGGCTGCTGACCTCCAACAGGAATTTCACCACTTAACCCTCTAGAAGTCCCACTACTTAATACTTCAGTACTTCTAGCATCAGGTGTTTCAATTACATGAGCTCATCATAGCCTTATAGAAGGTAAACGAAACCACATAAATCAAGCCCTACTAATTACCATTATACTAGGACTTTACTTCACCATCCTCCAAGCTTCAGAATACTTTGAAACATCATTCTCCATTTCAGATGGTATCTATGGTTCTACATTCTTCATGGCTACTGGATTCCATGGACTCCATGTAATTATTGGATCAACATTCCTTATTGTTTGCCTACTACGACAACTAAAATTTCACTTCACATCAAAACATCACTTCGGATTTGAAGCCGCAGCATGATACTGACATTTTGTAGACGTAGTCTGACTTTTCCTATACGTCTCCATTTATTGATGAGGATCTTACTCCCTTAGTATAATTAATATAACTGACTTCCAATTAGTAGATTCTGAATAAACCCAGAAGAGAGTAATTAACCTGTACACTGTTATCTTCATTAATATTTTATTATCCCTAACGCTAATTCTAGTTGCATTCTGACTCCCCCAAATAAATCTGTACTCAGAAAAAGCAAATCCATATGAATGCGGATTCGACCCTACAAGCTCTGCACGTCTACCATTCTCAATAAAATTTTTCTTGGTAGCAATTACATTTCTATTATTTGACCTAGAAATTGCTCTTCTACTTCCACTACCATGAGCAATTCAAACAATTAAAACCTCTACTATAATAATTATAGCCTTTATTCTAGTCACAATTCTATCTCTAGGCCTAGCATATGAATGAACACAAAAAGGATTAGAATGAACAGAGTAAATGGTAATTAGTTTAAAAAAAATTAATGATTTCGACTCATTAGATTATGATGATGTTCATAATTACCAATATGCCATCTACCTTCTTCAACCTCACCATAGCCTTCTCACTATCACTTCTAGGGACACTTATATTTCGCTCTCACCTAATATCCACATTACTATGCCTGGAAGGCATAGTATTATCCTTATTTA" diff --git a/test-data/variants-chrM-0-100.json b/test-data/variants-chrM-0-100.json new file mode 100644 index 00000000..8b957e1f --- /dev/null +++ b/test-data/variants-chrM-0-100.json @@ -0,0 +1 @@ +["{\"variant\":{\"contig\":\"chrM\",\"position\":10,\"end\":11,\"ref\":\"C\",\"alt\":\"G\"},\"sampleIds\":[\"s1\",\"s2\",\"s3\"]}","{\"variant\":{\"contig\":\"chrM\",\"position\":20,\"end\":21,\"ref\":\"G\",\"alt\":\"T\"},\"sampleIds\":[\"s1\",\"s3\"]}","{\"variant\":{\"contig\":\"chrM\",\"position\":23,\"end\":24,\"ref\":\"A\",\"alt\":\"C\"},\"sampleIds\":[\"s1\",\"s3\"]}"]