Skip to content

Commit

Permalink
Display indels and soft clips
Browse files Browse the repository at this point in the history
  • Loading branch information
danvk committed May 21, 2015
1 parent 0824803 commit db6fa48
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 64 deletions.
2 changes: 1 addition & 1 deletion playground.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@


var p = pileup.create('root', {
range: {contig: 'chr17', start: 7512959, stop: 7513547},
range: {contig: 'chr17', start: 7512444, stop: 7512484},
tracks: sources
});
</script>
113 changes: 91 additions & 22 deletions src/PileupTrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ var React = require('./react-shim'),
shallowEquals = require('shallow-equals'),
types = require('./react-types'),
utils = require('./utils'),
{addToPileup, getDifferingBasePairs} = require('./pileuputils'),
{addToPileup, getOpInfo} = require('./pileuputils'),
ContigInterval = require('./ContigInterval');

var PileupTrack = React.createClass({
Expand Down Expand Up @@ -40,13 +40,12 @@ var READ_SPACING = 2; // vertical pixels between reads
var READ_STRAND_ARROW_WIDTH = 6;

// Returns an SVG path string for the read, with an arrow indicating strand.
function makePath(scale, visualRead: VisualAlignment) {
var read = visualRead.read,
left = scale(visualRead.read.pos + 1),
function makeArrow(scale, pos, refLength, direction) {
var left = scale(pos + 1),
top = 0,
right = scale(read.pos + visualRead.refLength + 1) - 5,
right = scale(pos + refLength + 1),
bottom = READ_HEIGHT,
path = visualRead.strand == Strand.POSITIVE ? [
path = direction == 'R' ? [
[left, top],
[right - READ_STRAND_ARROW_WIDTH, top],
[right, (top + bottom) / 2],
Expand All @@ -62,6 +61,65 @@ function makePath(scale, visualRead: VisualAlignment) {
return d3.svg.line()(path);
}

// Create the SVG element for a single Cigar op in an alignment.
function enterSegment(parentNode, op, scale) {
if (op.op == 'M') {
if (op.arrow) {
return d3.select(parentNode)
.append('path')
.attr('class', 'segment match');
} else {
return d3.select(parentNode)
.append('rect')
.attr('class', 'segment match')
.attr('height', READ_HEIGHT);
}
} else if (op.op == 'D') {
return d3.select(parentNode)
.append('rect')
.attr('class', 'segment delete')
.attr('transform', `translate(0, ${READ_HEIGHT / 2 - 1})`)
.attr('height', 2);
} else if (op.op == 'I') {
return d3.select(parentNode)
.append('rect')
.attr('class', 'segment insert')
.attr('width', 2)
.attr('y', -1)
.attr('height', READ_HEIGHT + 2);
} else {
throw `Invalid op! ${op.op}`;
}
}

// Update the selection for a single Cigar op, e.g. in response to a pan or zoom.
function updateSegment(node, op, scale) {
if (op.op == 'M') {
if (op.arrow) {
d3.select(node)
.attr('d', makeArrow(scale, op.pos, op.length, op.arrow));
} else {
d3.select(node)
.attr('x', scale(op.pos + 1))
.attr('width', scale(op.length) - scale(0));
}
} else if (op.op == 'D') {
d3.select(node)
.attr('x', scale(op.pos + 1))
.attr('width', scale(op.length) - scale(0));
} else if (op.op == 'I') {
d3.select(node)
.attr('x', scale(op.pos + 1) - 2); // to cover a bit of the previous segment
} else {
throw `Invalid op! ${op.op}`;
}
}

// Should the Cigar op be rendered to the screen?
function isRendered(op) {
return (op.op == 'M' || op.op == 'D' || op.op == 'I');
}

function readClass(vread: VisualAlignment) {
return 'alignment ' + (vread.strand == Strand.NEGATIVE ? 'negative' : 'positive');
}
Expand Down Expand Up @@ -175,21 +233,18 @@ class NonEmptyPileupTrack extends React.Component {

var refLength = read.getReferenceLength();
var range = read.getInterval();
var reference = referenceSource.getRangeAsString({
contig: range.contig,
start: range.start(),
stop: range.stop()
});

var key = read.offset.toString();

var opInfo = getOpInfo(read, referenceSource);

var visualAlignment = {
key,
read,
strand: read.getStrand() == '+' ? Strand.POSITIVE : Strand.NEGATIVE,
row: addToPileup(range.interval, this.pileup),
refLength,
mismatches: getDifferingBasePairs(read, reference)
ops: opInfo.ops,
mismatches: opInfo.mismatches
};

this.keyToVisualAlignment[k] = visualAlignment;
Expand All @@ -202,14 +257,9 @@ class NonEmptyPileupTrack extends React.Component {
for (var k in this.keyToVisualAlignment) {
var vRead = this.keyToVisualAlignment[k],
read = vRead.read,
range = read.getInterval(),
reference = referenceSource.getRangeAsString({
contig: range.contig,
start: range.start(),
stop: range.stop()
});

vRead.mismatches = getDifferingBasePairs(read, reference);
opInfo = getOpInfo(read, referenceSource);

vRead.mismatches = opInfo.mismatches;
}
}

Expand Down Expand Up @@ -245,7 +295,23 @@ class NonEmptyPileupTrack extends React.Component {
window.alert(vRead.read.debugString());
});

var segments = reads.selectAll('.segment')
.data(read => read.ops.filter(isRendered));

// This is like segments.append(), but allows for different types of
// elements depending on the datum.
segments.enter().call(function(sel) {
sel.forEach(function(el) {
el.forEach(function(op) {
var d = d3.select(op).datum();
var element = enterSegment(el.parentNode, d, scale);
updateSegment(element[0][0], d, scale);
});
});
});

readsG.append('path'); // the alignment arrow

var mismatchTexts = reads.selectAll('text.basepair')
.data(vRead => vRead.mismatches, m => m.pos + m.basePair);

Expand All @@ -256,13 +322,16 @@ class NonEmptyPileupTrack extends React.Component {
.text(mismatch => mismatch.basePair);

// Update
reads.select('path').attr('d', (read, i) => makePath(scale, read));
segments.each(function(d, i) {
updateSegment(this, d, scale);
});
reads.selectAll('text')
.attr('x', mismatch => scale(1 + 0.5 + mismatch.pos)); // 0.5 = centered

// Exit
reads.exit().remove();
mismatchTexts.exit().remove();
segments.exit().remove();
}

}
Expand Down
102 changes: 90 additions & 12 deletions src/pileuputils.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,10 @@ type BasePair = {
basePair: string;
}

function getDifferingBasePairs(read: SamRead, reference: string): Array<BasePair> {
var cigar = read.getCigarOps();

// TODO: account for Cigars with clipping and indels
if (cigar.length != 1 || cigar[0].op != 'M') {
return [];
}
var range = read.getInterval(),
seq = read.getSequence(),
start = range.start();
function findMismatches(reference: string, seq: string, refPos: number): BasePair[] {
var out = [];
for (var i = 0; i < seq.length; i++) {
var pos = start + i,
var pos = refPos + i,
ref = reference.charAt(i),
basePair = seq.charAt(i);
if (ref != basePair) {
Expand All @@ -95,8 +86,95 @@ function getDifferingBasePairs(read: SamRead, reference: string): Array<BasePair
return out;
}

type OpInfo = {
ops: Object[],
mismatches: BasePair[]
}

// Determine which alignment segment to render as an arrow.
// This is either the first or last 'M' section, excluding soft clipping.
function getArrowIndex(read: SamRead): number {
var i, op, ops = read.getCigarOps();
if (read.getStrand() == '-') {
for (i = 0; i < ops.length; i++) {
op = ops[i];
if (op.op == 'S') continue;
if (op.op == 'M') return i;
return -1;
}
} else {
for (i = ops.length - 1; i >= 0; i--) {
op = ops[i];
if (op.op == 'S') continue;
if (op.op == 'M') return i;
return -1;
}
}
return -1;
}

// Breaks the read down into Cigar Ops suitable for display
function getOpInfo(read: SamRead, referenceSource: Object): OpInfo {
var ops = read.getCigarOps();

var range = read.getInterval(),
start = range.start(),
seq = read.getSequence(),
seqPos = 0,
refPos = start,
arrowIndex = getArrowIndex(read);

var result = [];
var mismatches = ([]: BasePair[]);
for (var i = 0; i < ops.length; i++) {
var op = ops[i];
if (op.op == 'M') {
var ref = referenceSource.getRangeAsString({
contig: range.contig,
start: refPos,
stop: refPos + op.length - 1
});
var mSeq = seq.slice(seqPos, seqPos + op.length);
mismatches = mismatches.concat(findMismatches(ref, mSeq, refPos));
}

result.push({
op: op.op,
length: op.length,
pos: refPos
});

switch (op.op) {
case 'M':
case 'D':
case 'N':
case '=':
case 'X':
refPos += op.length;
}

// TODO: flesh out this list.
switch (op.op) {
case 'M':
case 'I':
case 'S':
seqPos += op.length;
}

}

if (arrowIndex >= 0) {
result[arrowIndex].arrow = read.getStrand() == '-' ? 'L' : 'R';
}

return {
ops: result,
mismatches
};
}

module.exports = {
pileup,
addToPileup,
getDifferingBasePairs
getOpInfo
};
10 changes: 7 additions & 3 deletions style/pileup.css
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,20 @@
.pileup {
height: 300px;
}
.pileup .alignment path {
.pileup .alignment .match {
fill: #c8c8c8; /* matches IGV */
}
.pileup .alignment.negative path {
}
.pileup text.basepair {
alignment-baseline: hanging;
font-size: 12;
font-weight: bold;
}
.pileup .insert {
fill: rgb(97, 0, 216); /* matches IGV */
}
.pileup .delete {
fill: black;
}

/* variants */
.variants {
Expand Down
9 changes: 5 additions & 4 deletions test/PileupTrack-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,14 @@ describe('PileupTrack', function() {
.filter(function(d) { return (d.pos == pos) })[0];
},

// This checks that there are 12 C/T SNVs at chr17:7,500,765
// This checks that there are 22 C/T SNVs at chr17:7,500,765
// XXX: IGV only shows 20
assertHasColumnOfTs = () => {
var ref = elementsAtPos('.reference text.basepair', 7500765 - 1);
expect(ref).to.have.length(1);
expect(ref[0].textContent).to.equal('C');
var mismatches = elementsAtPos('.pileup text.basepair', 7500765 - 1);
expect(mismatches).to.have.length(12);
expect(mismatches).to.have.length(22);
_.each(mismatches, mm => {
expect(mm.textContent).to.equal('T');
});
Expand All @@ -172,7 +173,7 @@ describe('PileupTrack', function() {
// Some number of mismatches are expected, but it should be dramatically
// lower than the number of total base pairs in alignments.
var mismatches = testDiv.querySelectorAll('.pileup .alignment .basepair');
expect(mismatches).to.have.length.below(40);
expect(mismatches).to.have.length.below(60);
assertHasColumnOfTs();
});
});
Expand All @@ -190,7 +191,7 @@ describe('PileupTrack', function() {
return waitFor(hasReference, 2000);
}).then(() => {
var mismatches = testDiv.querySelectorAll('.pileup .alignment .basepair');
expect(mismatches).to.have.length.below(40);
expect(mismatches).to.have.length.below(60);
assertHasColumnOfTs();
});
});
Expand Down
Loading

0 comments on commit db6fa48

Please sign in to comment.