Skip to content

Commit

Permalink
Add zoom slider (#514)
Browse files Browse the repository at this point in the history
* control zoom slider to change viewable range
  • Loading branch information
chfzhang authored and akmorrow13 committed Apr 21, 2019
1 parent a1d78b0 commit c46cff4
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 4 deletions.
42 changes: 40 additions & 2 deletions src/main/Controls.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@ type Props = {
onChange: (newRange: GenomeRange)=>void;
};

class Controls extends React.Component<Props> {
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, State> {
props: Props;
state: void; // no state
state: State;

constructor(props: Object) {
super(props);
this.state = {defaultHalfInterval:2};
}

makeRange(): GenomeRange {
Expand Down Expand Up @@ -61,6 +69,13 @@ class Controls extends React.Component<Props> {
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`.
Expand All @@ -86,6 +101,20 @@ class Controls extends React.Component<Props> {
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;
Expand All @@ -96,6 +125,14 @@ class Controls extends React.Component<Props> {
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 {
Expand All @@ -114,6 +151,7 @@ class Controls extends React.Component<Props> {
<div className='zoom-controls'>
<button className='btn-zoom-out' onClick={this.zoomOut.bind(this)}></button>{' '}
<button className='btn-zoom-in' onClick={this.zoomIn.bind(this)}></button>
<input className='zoom-slider' ref ='slider' type="range" min="-15" max="0" onInput={this.handleSliderOnInput.bind(this)} class="slider"></input>
</div>
</form>
);
Expand Down
20 changes: 20 additions & 0 deletions src/main/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -299,6 +318,7 @@ module.exports = {
altContigName,
pipePromise,
scaleRange,
absScaleRange,
parseRange,
formatInterval,
isChrMatch,
Expand Down
57 changes: 55 additions & 2 deletions src/test/viz/GenomeTrack-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: [{
Expand Down Expand Up @@ -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},
Expand Down

0 comments on commit c46cff4

Please sign in to comment.