diff --git a/src/main/ResolutionCache.js b/src/main/ResolutionCache.js index d9ee0626..ae52da52 100644 --- a/src/main/ResolutionCache.js +++ b/src/main/ResolutionCache.js @@ -11,14 +11,10 @@ import _ from 'underscore'; import Interval from './Interval'; import ContigInterval from './ContigInterval'; -type ResolutionObject = { - resolution: number; - object: T; -} class ResolutionCache { coveredRanges: ResolutionCacheKey[]; - cache: {[key: string]: ResolutionObject}; + 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; @@ -35,19 +31,41 @@ class ResolutionCache { if (!range) return []; var res = {}; if (!resolution) { - res = _.map(_.filter(this.cache, d => this.filterFunction(range, d.object)), - obj => obj.object); + res = _.filter(this.cache[1], d => this.filterFunction(range, d)); } else { - res = _.map(_.filter(this.cache, d => this.filterFunction(range, d.object) && d.resolution == resolution), - obj => obj.object); + 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) { - var resolution = ResolutionCache.getResolution(range.interval); + 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 @@ -59,13 +77,13 @@ class ResolutionCache { if (!resolution) { resolution = 1; } - var resObject = { - resolution: resolution, - object: value - }; var key = this.keyFunction(value); - if (!this.cache[key]) { - this.cache[key] = resObject; + + // initialize cache resolution, if not already initialized + if (!this.cache[resolution]) this.cache[resolution] = {}; + + if (!this.cache[resolution][key]) { + this.cache[resolution][key] = value; } } @@ -101,11 +119,13 @@ class ResolutionCache { * - For regions >= 1,000,000, bin 1000 bp into 1 (return 1000) */ static getResolution(range: Interval): number { - if (range.length() < 10000) + // subtract one because length() adds one + var rangeLength = range.length() - 1; + if (rangeLength < 10000) return 1; - else if (range.length() >= 10000 && range.length() < 100000 ) + else if (rangeLength >= 10000 && rangeLength < 100000 ) return 10; - else if (range.length() >= 100000 && range.length() < 1000000 ) + else if (rangeLength >= 100000 && rangeLength < 1000000 ) return 100; else return 1000; diff --git a/src/main/data/GenotypeEndpoint.js b/src/main/data/GenotypeEndpoint.js deleted file mode 100644 index f29fe048..00000000 --- a/src/main/data/GenotypeEndpoint.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * This module defines a parser for the 2bit file format. - * See http://genome.ucsc.edu/FAQ/FAQformat.html#format7 - * @flow - */ -'use strict'; - -import ContigInterval from '../ContigInterval'; -import Q from 'q'; -import {RemoteRequest} from '../RemoteRequest'; -import type {Variant} from './vcf'; - -export type Genotype = { - sampleIds: string[], - variant: Variant -} - -class GenotypeEndpoint { - remoteRequest: RemoteRequest; - - constructor(remoteRequest: RemoteRequest) { - this.remoteRequest = remoteRequest; - } - - getFeaturesInRange(range: ContigInterval): Q.Promise { - return this.remoteRequest.get(range).then(e => { - return e.response; - }); - } -} - -module.exports = GenotypeEndpoint; diff --git a/src/main/data/VariantEndpoint.js b/src/main/data/VariantEndpoint.js deleted file mode 100644 index 1222c817..00000000 --- a/src/main/data/VariantEndpoint.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * This module defines a parser for the 2bit file format. - * See http://genome.ucsc.edu/FAQ/FAQformat.html#format7 - * @flow - */ -'use strict'; - -import ContigInterval from '../ContigInterval'; -import Q from 'q'; -import {RemoteRequest} from '../RemoteRequest'; -import type {Variant} from './vcf'; - -class VariantEndpoint { - remoteRequest: RemoteRequest; - - constructor(remoteRequest: RemoteRequest) { - this.remoteRequest = remoteRequest; - } - - getFeaturesInRange(range: ContigInterval): Q.Promise { - return this.remoteRequest.get(range).then(object => { - return object; - }); - } -} - -module.exports = VariantEndpoint; diff --git a/src/main/data/vcf.js b/src/main/data/vcf.js index eea346fe..00fd2b2d 100644 --- a/src/main/data/vcf.js +++ b/src/main/data/vcf.js @@ -20,6 +20,12 @@ export type Variant = { 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. type LocusLine = { contig: string; diff --git a/src/main/pileup.js b/src/main/pileup.js index 3b8d55b0..9e170b29 100644 --- a/src/main/pileup.js +++ b/src/main/pileup.js @@ -16,7 +16,6 @@ import ReferenceDataSource from './sources/ReferenceDataSource'; import BigBedDataSource from './sources/BigBedDataSource'; import VcfDataSource from './sources/VcfDataSource'; import VariantDataSource from './sources/VariantDataSource'; -import GenotypeDataSource from './sources/GenotypeDataSource'; import BamDataSource from './sources/BamDataSource'; import GA4GHDataSource from './sources/GA4GHDataSource'; import CoverageDataSource from './sources/CoverageDataSource'; @@ -132,7 +131,6 @@ var pileup = { ga4gh: GA4GHDataSource.create, vcf: VcfDataSource.create, variants: VariantDataSource.create, - genotypes: GenotypeDataSource.create, features: FeatureDataSource.create, twoBit: TwoBitDataSource.create, reference: ReferenceDataSource.create, diff --git a/src/main/sources/CoverageDataSource.js b/src/main/sources/CoverageDataSource.js index e2f00fc7..7d8151ad 100644 --- a/src/main/sources/CoverageDataSource.js +++ b/src/main/sources/CoverageDataSource.js @@ -72,30 +72,33 @@ function createFromCoverageUrl(remoteSource: RemoteRequest): CoverageDataSource 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); - var numRequests = 1; - o.trigger('networkprogress', {numRequests}); - return remoteSource.getFeaturesInRange(interval, 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)); + 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 { - // 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); + 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); + } } - } - o.trigger('networkdone'); - }); + o.trigger('networkdone'); + }))); } function getCoverageInRange(range: ContigInterval, diff --git a/src/main/sources/GenotypeDataSource.js b/src/main/sources/GenotypeDataSource.js deleted file mode 100644 index d9f85f1d..00000000 --- a/src/main/sources/GenotypeDataSource.js +++ /dev/null @@ -1,122 +0,0 @@ -/** - * 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 {Genotype} from '../data/GenotypeEndpoint'; - -import Q from 'q'; -import _ from 'underscore'; -import {Events} from 'backbone'; - -import ContigInterval from '../ContigInterval'; -import {RemoteRequest} from '../RemoteRequest'; -import GenotypeEndpoint from '../data/GenotypeEndpoint'; - -export type GenotypeDataSource = { - rangeChanged: (newRange: GenomeRange) => void; - getFeaturesInRange: (range: ContigInterval) => Genotype[]; - 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 = 1000; - -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 genotypeKey(v: Genotype): string { - return `${v.variant.contig}:${v.variant.position}`; -} - - -function createFromGenotypeUrl(remoteSource: GenotypeEndpoint): GenotypeDataSource { - var genotypes: {[key: string]: Genotype} = {}; - - // Ranges for which we have complete information -- no need to hit network. - var coveredRanges: ContigInterval[] = []; - - function addGenotype(v: Genotype) { - var key = genotypeKey(v); - if (!genotypes[key]) { - genotypes[key] = v; - } - } - - function fetch(range: GenomeRange) { - var interval = new ContigInterval(range.contig, range.start, range.stop); - - // Check if this interval is already in the cache. - if (interval.isCoveredBy(coveredRanges)) { - return Q.when(); - } - - interval = expandRange(interval); - - // "Cover" the range immediately to prevent duplicate fetches. - coveredRanges.push(interval); - coveredRanges = ContigInterval.coalesce(coveredRanges); - return remoteSource.getFeaturesInRange(interval).then(genotypes => { - if (genotypes !== null) - genotypes.forEach(genotype => addGenotype(genotype)); - o.trigger('newdata', interval); - }); - } - - function getFeaturesInRange(range: ContigInterval): Genotype[] { - if (!range) return []; // XXX why would this happen? - return _.filter(genotypes, v => range.chrContainsLocus(v.variant.contig, v.variant.position)); - } - - 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}): GenotypeDataSource { - if (!data.url) { - throw new Error(`Missing URL from track: ${JSON.stringify(data)}`); - } - var request = new RemoteRequest(data.url); - var endpoint = new GenotypeEndpoint(request); - return createFromGenotypeUrl(endpoint); -} - - -module.exports = { - create, - createFromGenotypeUrl -}; diff --git a/src/main/sources/VariantDataSource.js b/src/main/sources/VariantDataSource.js index 686a0bdf..1850a900 100644 --- a/src/main/sources/VariantDataSource.js +++ b/src/main/sources/VariantDataSource.js @@ -15,18 +15,18 @@ */ 'use strict'; -import type {Variant} from '../data/vcf'; +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'; -import VariantEndpoint from '../data/VariantEndpoint'; -var BASE_PAIRS_PER_FETCH = 1000; +var BASE_PAIRS_PER_FETCH = 10000; function expandRange(range: ContigInterval) { var roundDown = x => x - x % BASE_PAIRS_PER_FETCH; @@ -36,55 +36,84 @@ function expandRange(range: ContigInterval) { return new ContigInterval(range.contig, newStart, newStop); } -function variantKey(v: Variant): string { - return `${v.contig}:${v.position}`; +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: VariantEndpoint): VcfDataSource { - var variants: {[key: string]: Variant} = {}; - - // Ranges for which we have complete information -- no need to hit network. - var coveredRanges: ContigInterval[] = []; +function createFromVariantUrl(remoteSource: RemoteRequest, + samples?: string[]): VcfDataSource { - function addVariant(v: Variant) { - var key = variantKey(v); - if (!variants[key]) { - variants[key] = v; - } - } + var cache: ResolutionCache = + new ResolutionCache(filterFunction, keyFunction); function fetch(range: GenomeRange) { var interval = new ContigInterval(range.contig, range.start, range.stop); // Check if this interval is already in the cache. - if (interval.isCoveredBy(coveredRanges)) { + if (cache.coversRange(interval)) { 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. - coveredRanges.push(interval); - coveredRanges = ContigInterval.coalesce(coveredRanges); - return remoteSource.getFeaturesInRange(interval).then(e => { - var variants = e.response; - if (variants !== null) - variants.forEach(variant => addVariant(variant)); + // 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)); + } + o.trigger('networkdone'); o.trigger('newdata', interval); - }); + }))); } - function getFeaturesInRange(range: ContigInterval): Variant[] { + function getVariantsInRange(range: ContigInterval, resolution: ?number): Variant[] { if (!range) return []; // XXX why would this happen? - return _.filter(variants, v => range.chrContainsLocus(v.contig, v.position)); + 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(); }, - getFeaturesInRange, + getVariantsInRange, + getGenotypesInRange, + getSamples, // These are here to make Flow happy. on: () => {}, @@ -96,13 +125,15 @@ function createFromVariantUrl(remoteSource: VariantEndpoint): VcfDataSource { return o; } -function create(data: {url?:string}): VcfDataSource { +function create(data: {url?:string, samples?:string[]}): VcfDataSource { if (!data.url) { throw new Error(`Missing URL from track: ${JSON.stringify(data)}`); } - var request = new RemoteRequest(data.url, BASE_PAIRS_PER_FETCH); - var endpoint = new VariantEndpoint(request); - return createFromVariantUrl(endpoint); + if (!data.samples) { + console.log("no genotype samples provided"); + } + var endpoint = new RemoteRequest(data.url, BASE_PAIRS_PER_FETCH); + return createFromVariantUrl(endpoint, data.samples); } 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 a2488dad..13f353ec 100644 --- a/src/main/style.js +++ b/src/main/style.js @@ -65,6 +65,7 @@ module.exports = { // Genotype Track GENOTYPE_SPACING: 1, - GENOTYPE_FILL: '#9494b8', GENOTYPE_HEIGHT: 10, + GENOTYPE_FILL: '#999999', + BACKGROUND_FILL: '#f2f2f2', }; diff --git a/src/main/viz/CoverageTrack.js b/src/main/viz/CoverageTrack.js index 10fe02da..f088b191 100644 --- a/src/main/viz/CoverageTrack.js +++ b/src/main/viz/CoverageTrack.js @@ -58,7 +58,7 @@ class CoverageTiledCanvas extends TiledCanvas { .nice(); } - // This is alled by TiledCanvas over all tiles in a range + // This is called by TiledCanvas over all tiles in a range render(ctx: DataCanvasRenderingContext2D, xScale: (x: number)=>number, range: ContigInterval, diff --git a/src/main/viz/FeatureTrack.js b/src/main/viz/FeatureTrack.js index d9ce11d7..f29d20c4 100644 --- a/src/main/viz/FeatureTrack.js +++ b/src/main/viz/FeatureTrack.js @@ -149,9 +149,9 @@ class FeatureTrack extends React.Component { componentDidUpdate(prevProps: any, prevState: any) { if (!shallowEquals(this.props, prevProps) || !shallowEquals(this.state, prevState)) { - this.tiles.update(this.props.height, this.props.options); + this.tiles.update(this.props.options); this.tiles.invalidateAll(); - this.updateVisualization(); + this.updateVisualization(); } } diff --git a/src/main/viz/GenotypeTrack.js b/src/main/viz/GenotypeTrack.js index 310312ea..7fc3811a 100644 --- a/src/main/viz/GenotypeTrack.js +++ b/src/main/viz/GenotypeTrack.js @@ -4,132 +4,290 @@ */ 'use strict'; -import type {GenotypeDataSource} from '../sources/GenotypeDataSource'; -import type {Genotype} from '../data/GenotypeEndpoint'; +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 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 = 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: GenotypeDataSource}; - state: void; // no state + 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 { - 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 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.updateVisualization(); + this.tiles = new GenotypeTiledCanvas(this.props.source, + this.sampleIds, this.props.options); - this.props.source.on('newdata', () => { - this.updateVisualization(); - }); + // 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); + 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.updateVisualization(); + this.tiles.update(this.props.options); + this.tiles.invalidateAll(); + this.updateVisualization(); } } - updateVisualization() { - var canvas = ReactDOM.findDOMNode(this), + // 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) return; + if (width === 0 || typeof labelCanvas == 'undefined') return; - var ctx = canvasUtils.getContext(canvas); - var dtx = dataCanvas.getDataContext(ctx); - this.renderScene(dtx); - } + var height = yForRow(this.sampleIds.length); - renderScene(ctx: DataCanvasRenderingContext2D) { - var range = this.props.range, - interval = new ContigInterval(range.contig, range.start, range.stop), - genotypes = this.props.source.getFeaturesInRange(interval), - scale = this.getScale(), - sampleIds = []; - // add all samples to array of sample Ids - genotypes.forEach(genotype => { - var ids = genotype.sampleIds; - for (var i = 0; i < ids.length; i++) { - var id = ids[i]; - if (sampleIds.indexOf(id) < 0) { - sampleIds.push(id); - } + // 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(); + }); } - }); - sampleIds = sampleIds.sort(); // sort sample ids + } + } - // Height can only be computed after the genotypes has been updated. - var newHeight = yForRow(sampleIds.length); - var canvas = ReactDOM.findDOMNode(this), + updateVisualization() { + var canvas = (this.refs.canvas : HTMLCanvasElement), width = this.props.width; - d3utils.sizeCanvas(canvas, width, newHeight); - // This is a hack to adjust parent div for resize - var el = d3utils.findParent(canvas, 'track-content'); - console.log("el", el); - // if (el) el.style.height = newHeight; + // 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.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.reset(); - ctx.save(); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); - ctx.fillStyle = style.GENOTYPE_FILL; - genotypes.forEach(genotype => { - var variant = genotype.variant; - var keys = genotype.sampleIds; - ctx.pushObject(variant); - var x = Math.round(scale(variant.position)); - var width = Math.round(scale(variant.position + 1)) - 1 - x; - keys.forEach(sampleId => { - var y = yForRow(sampleIds.indexOf(sampleId)); - ctx.fillRect(x - 0.5, y - 0.5, width, style.GENOTYPE_HEIGHT); - }); - ctx.popObject(); - }); + // 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, - y = ev.offsetY, - canvas = ReactDOM.findDOMNode(this), - ctx = canvasUtils.getContext(canvas), - trackingCtx = new dataCanvas.ClickTrackingContext(ctx, x, y); - this.renderScene(trackingCtx); - var genotype = 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. + 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) { - alert(JSON.stringify(genotype)); + var variantString = `variant: ${JSON.stringify(genotype.variant)}`; + var samples = `samples with variant: ${JSON.stringify(genotype.sampleIds)}`; + alert(`${variantString}\n${samples}`); } } } diff --git a/src/main/viz/VariantTrack.js b/src/main/viz/VariantTrack.js index 709428ca..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(); - - 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, y - 0.2, width, style.VARIANT_HEIGHT); - ctx.strokeRect(x - 0.2, y - 0.2, 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/test/sources/GenotypeDataSource-test.js b/src/test/sources/GenotypeDataSource-test.js deleted file mode 100644 index 2854f87c..00000000 --- a/src/test/sources/GenotypeDataSource-test.js +++ /dev/null @@ -1,77 +0,0 @@ -/* @flow */ -'use strict'; - - -import {expect} from 'chai'; - -import sinon from 'sinon'; - -import GenotypeDataSource from '../../main/sources/GenotypeDataSource'; -import ContigInterval from '../../main/ContigInterval'; -import RemoteFile from '../../main/RemoteFile'; - -describe('GenotypeDataSource', function() { - var server: any = null, response; - - before(function () { - return new RemoteFile('/test-data/genotypes-chrM-0-100.json').getAllString().then(data => { - response = data; - server = sinon.fakeServer.create(); - server.respondWith('GET', '/genotypes/chrM?start=1&end=1000',[200, { "Content-Type": "application/json" }, response]); - server.respondWith('GET', '/genotypes/chrM?start=1000&end=2000',[200, { "Content-Type": "application/json" }, response]); - }); - }); - - after(function () { - server.restore(); - }); - - function getTestSource() { - var source = GenotypeDataSource.create({ - url: '/genotypes' - }); - return source; - } - - it('should extract features in a range', function(done) { - var source = getTestSource(); - var range = new ContigInterval('chrM', 0, 25); - // No genotypes are cached yet. - var genotypes = source.getFeaturesInRange(range); - expect(genotypes).to.deep.equal([]); - - source.on('newdata', () => { - var genotypes = source.getFeaturesInRange(range); - expect(genotypes).to.have.length(2); - expect(genotypes[1].sampleIds).to.contain('sample1'); - expect(genotypes[1].variant.contig).to.equal('chrM'); - expect(genotypes[1].variant.position).to.equal(20); - expect(genotypes[1].variant.ref).to.equal('G'); - expect(genotypes[1].variant.alt).to.equal('T'); - done(); - }); - source.rangeChanged({ - contig: range.contig, - start: range.start(), - stop: range.stop() - }); - server.respond(); - }); - - it('should not fail when no genotypes are available', function(done) { - var source = getTestSource(); - var range = new ContigInterval('chrM', 1000, 1025); - - source.on('newdata', () => { - var genotypes = source.getFeaturesInRange(range); - expect(genotypes).to.have.length(0); - done(); - }); - source.rangeChanged({ - contig: range.contig, - start: range.start(), - stop: range.stop() - }); - server.respond(); - }); -}); diff --git a/src/test/sources/VariantDataSource-test.js b/src/test/sources/VariantDataSource-test.js index 9ee2a7ed..27a7b740 100644 --- a/src/test/sources/VariantDataSource-test.js +++ b/src/test/sources/VariantDataSource-test.js @@ -17,7 +17,7 @@ describe('VariantDataSource', 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=1000',[200, { "Content-Type": "application/json" }, response]); + 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" }, '']); }); }); @@ -28,7 +28,8 @@ describe('VariantDataSource', function() { function getTestSource() { var source = VariantDataSource.create({ - url: '/variants' + url: '/variants', + samples: ["sample1", "sample2", "sample3"] }); return source; } @@ -36,11 +37,11 @@ describe('VariantDataSource', function() { var source = getTestSource(); var range = new ContigInterval('chrM', 0, 50); // 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(3); expect(variants[1].contig).to.equal('chrM'); expect(variants[1].position).to.equal(20); @@ -61,7 +62,7 @@ describe('VariantDataSource', function() { var range = new ContigInterval('chrM', 1050, 1150); source.on('newdata', () => { - var variants = source.getFeaturesInRange(range); + var variants = source.getVariantsInRange(range); expect(variants).to.deep.equal([]); done(); }); 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/GenotypeTrack-test.js b/src/test/viz/GenotypeTrack-test.js index b43f934a..23db3768 100644 --- a/src/test/viz/GenotypeTrack-test.js +++ b/src/test/viz/GenotypeTrack-test.js @@ -59,7 +59,7 @@ describe('GenotypeTrack', function() { isReference: true }, { - data: pileup.formats.genotypes({ + data: pileup.formats.variants({ url: '/test-data/genotypes-17.json' }), viz: pileup.viz.genotypes(), @@ -74,8 +74,6 @@ describe('GenotypeTrack', function() { expect(genotypes).to.have.length(3); expect(genotypes.map(g => g.variant.position)).to.deep.equal( [10, 20, 30]); - expect(genotypes.map(g => g.sampleIds)).to.deep.equal( - [sampleIds, sampleIds, sampleIds]); p.destroy(); }); diff --git a/style/pileup.css b/style/pileup.css index 8e91c34c..64110d54 100644 --- a/style/pileup.css +++ b/style/pileup.css @@ -18,6 +18,10 @@ .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; @@ -177,7 +181,7 @@ } /* pileup track */ -.pileup-root > .pileup { +.pileup-root > .pileup, .pileup-root > .genotypes { flex: 1; /* stretch to fill remaining space */ } .pileup .alignment .match { diff --git a/test-data/variants-chrM-0-100.json b/test-data/variants-chrM-0-100.json index 44598dae..3293a56e 100644 --- a/test-data/variants-chrM-0-100.json +++ b/test-data/variants-chrM-0-100.json @@ -1,16 +1,23 @@ [{ - "contig": "chrM", - "position": 10, - "ref": "C", - "alt": "G" + "variant" : { + "contig": "chrM", + "position": 10, + "ref": "C", + "alt": "G" + }, "sampleIds": \["sample1", "sample2", "sample3"\] }, { - "contig": "chrM", - "position": 20, - "ref": "G", - "alt": "T" + "variant" : { + "contig": "chrM", + "position": 20, + "ref": "G", + "alt": "T" + }, "sampleIds": ["sample1", "sample3"] }, { - "contig": "chrM", - "position": 23, - "ref": "A", - "alt": "C" + "variant" : { + "contig": "chrM", + "position": 23, + "ref": "A", + "alt": "C" + }, "sampleIds": ["sample1"] }] +["{\"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\"]}"]