diff --git a/src/main/Controls.js b/src/main/Controls.js index f4e0d440..f11ce064 100644 --- a/src/main/Controls.js +++ b/src/main/Controls.js @@ -18,12 +18,20 @@ type Props = { onChange: (newRange: GenomeRange)=>void; }; -class Controls extends React.Component { +type State = { + // the base number to be used for absolute zoom + // new ranges become 2*defaultHalfInterval**zoomLevel + 1 + // half interval is simply the span cut in half and excluding the center + defaultHalfInterval: number; +}; + +class Controls extends React.Component { props: Props; - state: void; // no state + state: State; constructor(props: Object) { super(props); + this.state = {defaultHalfInterval:2}; } makeRange(): GenomeRange { @@ -61,6 +69,13 @@ class Controls extends React.Component { e.preventDefault(); var range = this.completeRange(utils.parseRange(this.refs.position.value)); this.props.onChange(range); + this.updateSlider(new Interval(range.start, range.stop)); + } + + handleSliderOnInput(){ + // value is a string, want valueAsNumber + // slider has negative values to reverse its direction so we need to negate + this.zoomAbs(-this.refs.slider.valueAsNumber); } // Sets the values of the input elements to match `props.range`. @@ -86,6 +101,20 @@ class Controls extends React.Component { this.zoomByFactor(2.0); } + // Updates the range using absScaleRange and a given zoom level + // Abs or absolute because it doesn't rely on scaling the current range + zoomAbs(level: number) { + var r = this.props.range; + if (!r) return; + + var iv = utils.absScaleRange(new Interval(r.start, r.stop), level, this.state.defaultHalfInterval); + this.props.onChange({ + contig: r.contig, + start: iv.start, + stop: iv.stop + }); + } + zoomByFactor(factor: number) { var r = this.props.range; if (!r) return; @@ -96,6 +125,14 @@ class Controls extends React.Component { start: iv.start, stop: iv.stop }); + this.updateSlider(iv); + } + + // To be used if the range changes through a control besides the slider + // Slider value is changed to roughly reflect the new range + updateSlider(newInterval: Interval) { + var newSpan = (newInterval.stop - newInterval.start); + this.refs.slider.valueAsNumber = Math.ceil(-Math.log2(newSpan) + 1); } render(): any { @@ -114,6 +151,7 @@ class Controls extends React.Component {
{' '} +
); diff --git a/src/main/utils.js b/src/main/utils.js index 4d9e3cc6..db52122f 100755 --- a/src/main/utils.js +++ b/src/main/utils.js @@ -182,6 +182,25 @@ function scaleRange(range: Interval, factor: number): Interval { return new Interval(start, stop); } +/** + * Changes the range span to 2*halfSpan**level + 1 + * New range looks like | halfSpan**level | center | halfSpan**level | + * An invariant is that the center value will be identical before and after. + */ +function absScaleRange(range: Interval, level: number, halfSpan: number): Interval { + var center = Math.floor((range.start + range.stop) / 2), + newHalfSpan = halfSpan**level, + start = center - newHalfSpan, + stop = center + newHalfSpan; // TODO: clamp + + if (start < 0) { + // Shift to the right so that the range starts at zero. + stop -= start; + start = 0; + } + return new Interval(start, stop); +} + /** * Parse a user-specified range into a range. * Only the specified portions of the range will be filled out in the returned object. @@ -299,6 +318,7 @@ module.exports = { altContigName, pipePromise, scaleRange, + absScaleRange, parseRange, formatInterval, isChrMatch, diff --git a/src/test/viz/GenomeTrack-test.js b/src/test/viz/GenomeTrack-test.js index 8e7427af..644f2c2c 100644 --- a/src/test/viz/GenomeTrack-test.js +++ b/src/test/viz/GenomeTrack-test.js @@ -21,7 +21,7 @@ import {waitFor} from '../async'; describe('GenomeTrack', function() { var testDiv = document.getElementById('testdiv'); if (!testDiv) throw new Error("Failed to match: testdiv"); - + beforeEach(() => { // A fixed width container results in predictable x-positions for mismatches. testDiv.style.width = '800px'; @@ -94,7 +94,7 @@ describe('GenomeTrack', function() { * (tens of nucleotides). */ it('should zoom from huge zoom out', function(): any { - + var p = pileup.create(testDiv, { range: { contig: '17', start: 0, stop: 114529884 }, tracks: [{ @@ -169,6 +169,59 @@ describe('GenomeTrack', function() { }); }); + it('should zoom according to the value of the slider', function(): any { + var p = pileup.create(testDiv, { + range: {contig: '17', start: 7500725, stop: 7500775}, + tracks: [ + { + data: referenceSource, + viz: pileup.viz.genome(), + isReference: true + } + ] + }); + + expect(testDiv.querySelectorAll('.zoom-controls')).to.have.length(1); + expect(testDiv.querySelectorAll('.zoom-slider')).to.have.length(1); + // querySelectorAll returns HTMLElement + // cast to any and then to HTMLInputElement to make flow happy + var slider = ((testDiv.querySelectorAll('.zoom-slider')[0]: any): HTMLInputElement); + var [locationTxt] = getInputs('.controls input[type="text"]'); + + return waitFor(hasReference, 2000).then(() => { + slider.value = "-1"; + ReactTestUtils.Simulate.input(slider); + + }).delay(50).then(() => { + + expect(p.getRange()).to.deep.equal({ + contig: 'chr17', + start: 7500748, + stop: 7500752 + }); + expect(locationTxt.value).to.equal('7,500,748-7,500,752'); + slider.value = "-2"; + ReactTestUtils.Simulate.input(slider); + }).delay(50).then(() => { + expect(p.getRange()).to.deep.equal({ + contig: 'chr17', + start: 7500746, + stop: 7500754 + }); + expect(locationTxt.value).to.equal('7,500,746-7,500,754'); + slider.value = "-5"; + ReactTestUtils.Simulate.input(slider); + }).delay(50).then(() => { + expect(p.getRange()).to.deep.equal({ + contig: 'chr17', + start: 7500718, + stop: 7500782 + }); + expect(locationTxt.value).to.equal('7,500,718-7,500,782'); + p.destroy(); + }); + }); + it('should accept user-entered locations', function(): any { var p = pileup.create(testDiv, { range: {contig: '17', start: 7500725, stop: 7500775},