diff --git a/Gruntfile.js b/Gruntfile.js index 360894ed..74271122 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -26,7 +26,7 @@ module.exports = function(grunt) { }, prod: { files: ['<%= watch.flow.files %>'], - tasks: ['prod'] + tasks: ['browserify:dist'] } }, browserify: { @@ -55,6 +55,13 @@ module.exports = function(grunt) { } } }, + uglify: { + dist: { + files: { + 'build/all.min.js': ['build/all.js'] + } + } + }, jscoverage: { src: { expand: true, @@ -93,13 +100,14 @@ module.exports = function(grunt) { grunt.loadNpmTasks('grunt-mocha-phantomjs'); grunt.loadNpmTasks("grunt-jscoverage"); grunt.loadNpmTasks("grunt-exorcise"); + grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.registerTask('watchFlow', ['flow:app:start', 'watch:flow']); grunt.registerTask('watchFlowProd', ['flow:app:start', 'watch:flowProd']); - grunt.registerTask('prod', ['browserify:dist']); + grunt.registerTask('prod', ['browserify:dist', 'uglify:dist']); grunt.registerTask('browsertests', ['browserify:test']); grunt.registerTask('test', ['browsertests', 'mocha_phantomjs:run']); grunt.registerTask('travis', ['flow', 'test']); grunt.registerTask('coverage', - ['browsertests', 'exorcise', 'jscoverage', 'mocha_phantomjs:cov']); + ['browsertests', 'exorcise:bundle', 'jscoverage', 'mocha_phantomjs:cov']); }; diff --git a/README.md b/README.md index a0b59f23..5cb642ef 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,6 @@ To iterate on code while running the type checker: To continuously regenerate the combined JS, run: - grunt watchFlowProd + grunt watch:prod [hs]: https://github.com/nodeapps/http-server diff --git a/lib/underscore.js b/lib/underscore.js index e1e9e4a0..1007f04e 100644 --- a/lib/underscore.js +++ b/lib/underscore.js @@ -3,5 +3,11 @@ declare module "underscore" { declare function findWhere(list: Array, properties: {}): ?T; declare function clone(obj: T): T; + + declare function isEqual(a: S, b: T): boolean; + declare function range(a: number, b: number): Array; + declare function extend(...o: Object): Object; + + declare function chain(obj: S): any; } diff --git a/package.json b/package.json index c3d45b1b..de6e43a6 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "homepage": "https://github.com/danvk/pileup.js", "dependencies": { + "backbone": "^1.1.2", "d3": "^3.5.5", "q": "^1.1.2", "react": "^0.12.2", @@ -26,6 +27,7 @@ "flow-bin": "^0.4.0", "grunt": "^0.4.5", "grunt-browserify": "^3.3.0", + "grunt-contrib-uglify": "^0.8.0", "grunt-contrib-watch": "^0.6.1", "grunt-env": "^0.4.2", "grunt-exorcise": "^1.0.0", diff --git a/playground.html b/playground.html index 0e29a3bc..642b1d9b 100644 --- a/playground.html +++ b/playground.html @@ -2,14 +2,18 @@ diff --git a/src/GenomeTrack.js b/src/GenomeTrack.js index e91e5132..8f0cea76 100644 --- a/src/GenomeTrack.js +++ b/src/GenomeTrack.js @@ -3,7 +3,7 @@ * @flow */ -var React = require('react'), +var React = require('react/addons'), _ = require('underscore'), d3 = require('d3'), types = require('./types'); @@ -11,7 +11,7 @@ var React = require('react'), var GenomeTrack = React.createClass({ propTypes: { range: types.GenomeRange, - basePairs: React.PropTypes.string, + basePairs: React.PropTypes.object, onRangeChange: React.PropTypes.func.isRequired }, render: function(): any { @@ -33,9 +33,12 @@ var GenomeTrack = React.createClass({ }); var NonEmptyGenomeTrack = React.createClass({ + // This prevents updates if state & props have not changed. + mixins: [React.addons.PureRenderMixin], + propTypes: { range: types.GenomeRange.isRequired, - basePairs: React.PropTypes.string.isRequired, + basePairs: React.PropTypes.object.isRequired, onRangeChange: React.PropTypes.func.isRequired }, render: function(): any { @@ -97,7 +100,13 @@ var NonEmptyGenomeTrack = React.createClass({ return scale; }, componentDidUpdate: function(prevProps: any, prevState: any) { - this.updateVisualization(); + // Check a whitelist of properties which could change the visualization. + // For now, just basePairs and range. + var newProps = this.props; + if (!_.isEqual(newProps.basePairs, prevProps.basePairs) || + !_.isEqual(newProps.range, prevProps.range)) { + this.updateVisualization(); + } }, updateVisualization: function() { var div = this.getDOMNode(), @@ -108,10 +117,12 @@ var NonEmptyGenomeTrack = React.createClass({ var scale = this.getScale(); - var absBasePairs = [].map.call(this.props.basePairs, (bp, i) => ({ - position: i + range.start, - letter: bp - })); + var contigColon = this.props.range.contig + ':'; + var absBasePairs = _.range(range.start - 1, range.stop + 1) + .map(locus => ({ + position: locus, + letter: this.props.basePairs[contigColon + locus] + })); svg.attr('width', width) .attr('height', height); diff --git a/src/Root.js b/src/Root.js index 2c55859f..f7ea5be9 100644 --- a/src/Root.js +++ b/src/Root.js @@ -6,9 +6,10 @@ var React = require('react'), Controls = require('./Controls'), GenomeTrack = require('./GenomeTrack'), - types = require('./types'), // TODO: make this an "import type" when react-tools 0.13.0 is out. - TwoBit = require('./TwoBit'); + TwoBitDataSource = require('./TwoBitDataSource'), + types = require('./types'); + var Root = React.createClass({ propTypes: { @@ -23,24 +24,35 @@ var Root = React.createClass({ }, componentDidMount: function() { // Note: flow is unable to infer this type through `this.propTypes`. - var ref: TwoBit = this.props.referenceSource; - ref.getContigList().then(contigList => { - this.setState({contigList}); + var source = this.props.referenceSource; + source.needContigs(); + + source.on('contigs', () => { this.update() }) + .on('newdata', () => { this.update() }) + + source.on('contigs', () => { // this is here to facilitate faster iteration this.handleRangeChange({ contig: 'chr1', start: 123456, stop: 123500 }); - }).done(); + }); + + this.update(); + }, + update: function() { + this.setState({ + contigList: this.props.referenceSource.contigList(), + basePairs: this.props.referenceSource.getRange(this.state.range) + }); }, handleRangeChange: function(newRange: GenomeRange) { - this.setState({range: newRange, basePairs: null}); + this.setState({range: newRange}); + this.update(); + var ref = this.props.referenceSource; - ref.getFeaturesInRange(newRange.contig, newRange.start, newRange.stop) - .then(basePairs => { - this.setState({basePairs}); - }).done(); + ref.rangeChanged(newRange); }, render: function(): any { return ( diff --git a/src/TwoBitDataSource.js b/src/TwoBitDataSource.js new file mode 100644 index 00000000..231ada92 --- /dev/null +++ b/src/TwoBitDataSource.js @@ -0,0 +1,85 @@ +/** + * 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 * as TwoBit from './TwoBit'; + +var Events = require('backbone').Events, + _ = require('underscore'); + +// TODO: make this an "import type" when react-tools 0.13.0 is out. +var TwoBit = require('./TwoBit'); + +// Factor by which to over-request data from the network. +// If a range of 100bp is shown and this is 2.0, then 300 base pairs will be +// requested over the network (100 * 2.0 too much) +var EXPANSION_FACTOR = 2.0; + + +// TODO: make the return type more precise +var createTwoBitDataSource = function(remoteSource: TwoBit): any { + // Local cache of genomic data. + var contigList = []; + var basePairs = {}; // contig -> locus -> letter + function getBasePair(contig: string, position: number) { + return basePairs[contig] && basePairs[contig][position]; + } + function setBasePair(contig: string, position: number, letter: string) { + if (!basePairs[contig]) basePairs[contig] = {}; + basePairs[contig][position] = letter; + } + + function fetch(range: GenomeRange) { + return remoteSource.getFeaturesInRange(range.contig, range.start, range.stop) + .then(letters => { + for (var i = 0; i < letters.length; i++) { + setBasePair(range.contig, range.start + i, letters[i]); + } + }); + } + + // Returns a {"chr12:123" -> "[ATCG]"} mapping for the range. + function getRange(range: GenomeRange) { + if (!range) return null; + return _.chain(_.range(range.start, range.stop)) + .map(x => [range.contig + ':' + x, getBasePair(range.contig, x)]) + .object() + .value(); + } + + var o = { + rangeChanged: function(newRange: GenomeRange) { + // Range has changed! Fetch new data. + // TODO: only fetch it if it isn't cached already. + fetch(newRange) + .then(() => o.trigger('newdata', newRange)) + .done(); + }, + needContigs: () => { + remoteSource.getContigList().then(c => { + contigList = c; + o.trigger('contigs', contigList); + }).done(); + }, + getRange: getRange, + contigList: () => contigList + }; + _.extend(o, Events); // Make this an event emitter + + return o; +}; + +module.exports = createTwoBitDataSource; diff --git a/src/main.js b/src/main.js index e4fdfcfe..a1c2688c 100644 --- a/src/main.js +++ b/src/main.js @@ -1,11 +1,13 @@ /* @flow */ var React = require('react'), TwoBit = require('./TwoBit'), - Root = require('./Root'); + Root = require('./Root'), + createTwoBitDataSource = require('./TwoBitDataSource'); var startMs = Date.now(); // var genome = new TwoBit('http://www.biodalliance.org/datasets/hg19.2bit'); var genome = new TwoBit('/hg19.2bit'); +var dataSource = createTwoBitDataSource(genome); genome.getFeaturesInRange('chr22', 19178140, 19178170).then(basePairs => { var endMs = Date.now(); @@ -19,5 +21,5 @@ genome.getFeaturesInRange('chr22', 19178140, 19178170).then(basePairs => { // pre-load some data to allow network-free panning genome.getFeaturesInRange('chr1', 123000, 124000).done(); -var root = React.render(, +var root = React.render(, document.getElementById('root')); diff --git a/types/types.js b/types/types.js index af5e8911..5daabf22 100644 --- a/types/types.js +++ b/types/types.js @@ -1,5 +1,5 @@ declare class GenomeRange { contig: string; start: number; - stop: number; + stop: number; // XXX inclusive or exclusive? }