Skip to content

Commit

Permalink
Merge pull request #266 from hammerlab/migrate-coverage
Browse files Browse the repository at this point in the history
Migrate Coverage from SVG→canvas
  • Loading branch information
armish committed Sep 25, 2015
2 parents ae93a2e + 5a07209 commit 04dacde
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 91 deletions.
149 changes: 64 additions & 85 deletions src/main/CoverageTrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ var React = require('./react-shim'),
types = require('./react-types'),
d3utils = require('./d3utils'),
_ = require("underscore"),
dataCanvas = require('./data-canvas'),
style = require('./style'),
ContigInterval = require('./ContigInterval');


Expand Down Expand Up @@ -55,55 +57,14 @@ class CoverageTrack extends React.Component {
}

render(): any {
// Fill the container up
// as we are going to use almost all the space available
var containerStyles = {
'height': '100%'
};

return (
<div>
<div ref='container' style={containerStyles}></div>
</div>
);
return <canvas ref='canvas' />;
}

getScale() {
return d3utils.getTrackScale(this.props.range, this.props.width);
}

/**
* Create a dummy label to see how much space the label occupies.
* Once calculated, save it into the state for later use in
* padding-related calculations.
*/
calculateLabelSize(svg) {
// Create a dummy element that matches with the style:
// ".coverage .y-axis g.tick text {...}"
var dummyAxis = svg.append('g').attr('class', 'y-axis');
var dummySize = dummyAxis.append('g').attr('class', 'tick')
.append('text')
.text("100X")
.node()
.getBBox(); // Measure its size

// Save the size information
this.setState({
labelSize: {height: dummySize.height, width: dummySize.width}
});
dummyAxis.remove();
}

componentDidMount() {
var div = this.refs.container.getDOMNode();
var svg = d3.select(div).append('svg');
this.calculateLabelSize(svg);

// Below, we create the container group for our bars upfront as we want
// to overlay the axis on top of them; therefore, we have to make sure
// their container is defined first in the SVG
svg.append('g').attr('class', 'bin-group');

this.props.source.on('newdata', () => {
var ci = new ContigInterval(this.props.range.contig, 0, Number.MAX_VALUE),
reads = this.props.source.getAlignmentsInRange(ci),
Expand All @@ -130,62 +91,80 @@ class CoverageTrack extends React.Component {
({position}) => (position >= start && position <= stop));
}

getContext(): CanvasRenderingContext2D {
var canvas = (this.refs.canvas.getDOMNode() : HTMLCanvasElement);
// The typecast through `any` is because getContext could return a WebGL context.
var ctx = ((canvas.getContext('2d') : any) : CanvasRenderingContext2D);
return ctx;
}

visualizeCoverage() {
var div = this.refs.container.getDOMNode(),
var canvas = (this.refs.canvas.getDOMNode() : HTMLCanvasElement),
width = this.props.width,
height = this.props.height,
padding = this.state.labelSize.height / 2, // half the text height
xScale = this.getScale(),
svg = d3.select(div).select('svg');
padding = 10,
xScale = this.getScale();

// Hold off until height & width are known.
if (width === 0) return;

svg.attr('width', width).attr('height', height);
d3.select(canvas).attr({width, height});

var yScale = d3.scale.linear()
.domain([this.state.maxCoverage, 0]) // mind the inverted axis
.domain([this.state.maxCoverage, 0])
.range([padding, height - padding])
.nice();
// The nice() call on the axis will give us a new domain to work with
// Let's get our domain max back from the nicified scale
var axisMax = yScale.domain()[0];
// Select the group we created first
var histBars = svg.select('g.bin-group').selectAll('rect.bin')
.data(this.binsInRange(), d => d.position);

var calcBarHeight = d => Math.max(0, yScale(axisMax - d.count)),
calcBarPosY = d => yScale(d.count) - yScale(axisMax),
calcBarWidth = d => xScale(d.position) - xScale(d.position - 1);

// D3 logic for our histogram bars
histBars
.enter()
.append('rect')
.attr('class', 'bin');
histBars
.attr('x', d => xScale(d.position))
.attr('y', calcBarPosY)
.attr('width', calcBarWidth)
.attr('height', calcBarHeight)
histBars.exit().remove();

// Logic for our axis
var yAxis = d3.svg.axis()
.scale(yScale)
.orient('right') // this is gonna be at the far left
.innerTickSize(5) // Make our ticks much more visible
.outerTickSize(0) // Remove the default range ticks (they are ugly)
.tickFormat(t => t + 'X') // X -> times in coverage terminology
.tickValues([0, Math.round(axisMax / 2), axisMax]); // show min, avg, max
var yAxisEl = svg.selectAll('g.y-axis');
if (yAxisEl.empty()) { // no axis element yet
svg.append('rect').attr('class', 'y-axis-background');
// add this the second so it is on top of the background
svg.append('g').attr('class', 'y-axis');
} else {
yAxisEl.call(yAxis); // update the axis
}

var ctx = dataCanvas.getDataContext(this.getContext());
ctx.save();
ctx.reset();
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

ctx.fillStyle = style.COVERAGE_BIN_COLOR;
var barWidth = xScale(1) - xScale(0),
barPadding = barWidth * style.COVERAGE_BIN_PADDING_CONSTANT;

// Draw coverage bins
this.binsInRange().forEach(bin => {
ctx.pushObject(bin);
var barPosX = xScale(bin.position),
barPosY = yScale(bin.count) - yScale(axisMax),
barHeight = Math.max(0, yScale(axisMax - bin.count));
ctx.fillRect(barPosX + barPadding,
barPosY,
barWidth - barPadding,
barHeight);
ctx.popObject();
});

// Draw three ticks
[0, Math.round(axisMax / 2), axisMax].forEach(tick => {
// Draw a line indicating the tick
ctx.pushObject({value: tick, type: 'tick'});
ctx.beginPath();
var tickPosY = yScale(tick);
ctx.moveTo(0, tickPosY);
ctx.lineTo(style.COVERAGE_TICK_LENGTH, tickPosY);
ctx.stroke();
ctx.popObject();

var tickLabel = tick + 'X';
ctx.pushObject({value: tick, label: tickLabel, type: 'label'});
// Now print the coverage information
ctx.lineWidth = 1;
ctx.fillStyle = style.COVERAGE_FONT_COLOR;
ctx.font = style.COVERAGE_FONT_STYLE;
var textPosY = tickPosY + style.COVERAGE_TEXT_Y_OFFSET;
ctx.fillText(tickLabel,
style.COVERAGE_TICK_LENGTH + style.COVERAGE_TEXT_PADDING,
textPosY);
ctx.popObject();
// Clean up with this tick
});

ctx.restore();
}
}

Expand Down
14 changes: 12 additions & 2 deletions src/main/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ module.exports = {
},

// Styles for base pairs which are rendered as letters
LOOSE_TEXT_STYLE: '24px Helvetica Neue, Helvetica, Arial, sans-serif',
TIGHT_TEXT_STYLE: 'bold 12px Helvetica Neue, Helvetica, Arial, sans-serif',
LOOSE_TEXT_STYLE: `24px 'Helvetica Neue', Helvetica, Arial, sans-serif`,
TIGHT_TEXT_STYLE: `bold 12px 'Helvetica Neue', Helvetica, Arial, sans-serif`,

// Gene track
GENE_ARROW_SIZE:4,
Expand All @@ -32,4 +32,14 @@ module.exports = {
ALIGNMENT_COLOR: '#c8c8c8',
DELETE_COLOR: 'black',
INSERT_COLOR: 'rgb(97, 0, 216)',

// Coverage track
COVERAGE_FONT_STYLE: `bold 9px 'Helvetica Neue', Helvetica, Arial, sans-serif`,
COVERAGE_FONT_COLOR: 'black',
COVERAGE_TICK_LENGTH: 5,
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',
COVERAGE_BIN_PADDING_CONSTANT: 0.01, // 1% of bar width

};
18 changes: 14 additions & 4 deletions src/test/CoverageTrack-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ var pileup = require('../main/pileup'),
RemoteFile = require('../main/RemoteFile'),
MappedRemoteFile = require('./MappedRemoteFile'),
ContigInterval = require('../main/ContigInterval'),
dataCanvas = require('../main/data-canvas'),
{waitFor} = require('./async');

describe('CoverageTrack', function() {
var testDiv = document.getElementById('testdiv');
var range = {contig: '17', start: 7500730, stop: 7500790};

beforeEach(() => {
dataCanvas.RecordingContext.recordAll();
// A fixed width container results in predictable x-positions for mismatches.
testDiv.style.width = '800px';
var p = pileup.create(testDiv, {
Expand All @@ -53,6 +55,7 @@ describe('CoverageTrack', function() {
});

afterEach(() => {
dataCanvas.RecordingContext.reset();
// avoid pollution between tests.
testDiv.innerHTML = '';
testDiv.style.width = '';
Expand All @@ -62,12 +65,14 @@ describe('CoverageTrack', function() {
[[0, 16383], [691179834, 691183928], [694008946, 694009197]]),
referenceSource = TwoBitDataSource.createFromTwoBitFile(new TwoBit(twoBitFile));

var {drawnObjectsWith, callsOf} = dataCanvas.RecordingContext;

var findCoverageBins = () => {
return testDiv.querySelectorAll('.coverage .bin');
return drawnObjectsWith(testDiv, '.coverage', b => b.position);
};

var findCoverageLabels = () => {
return testDiv.querySelectorAll('.coverage .y-axis text');
return drawnObjectsWith(testDiv, '.coverage', l => l.type == 'label')
};

var hasCoverage = () => {
Expand All @@ -84,9 +89,14 @@ describe('CoverageTrack', function() {

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].textContent).to.equal('0X');
expect(labelTexts[labelTexts.length-1].textContent).to.equal('50X');
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']);
});
});

Expand Down

0 comments on commit 04dacde

Please sign in to comment.