Skip to content

Commit

Permalink
Merge pull request #157 from hammerlab/indels
Browse files Browse the repository at this point in the history
Show Indels and Soft Clip alignments
  • Loading branch information
danvk committed May 22, 2015
2 parents 7727319 + 4ba7982 commit 22a999b
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 65 deletions.
134 changes: 110 additions & 24 deletions src/PileupTrack.js
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, CigarOp} = 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,80 @@ 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) {
var parent = d3.select(parentNode);
switch (op.op) {
case CigarOp.MATCH:
return parent.append(op.arrow ? 'path' : 'rect')
.attr('class', 'segment match');

case CigarOp.DELETE:
return parent.append('line')
.attr('class', 'segment delete');

case CigarOp.INSERT:
return parent.append('line')
.attr('class', 'segment insert');

default:
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) {
switch (op.op) {
case CigarOp.MATCH:
if (op.arrow) {
// an arrow pointing in the direction of the alignment
d3.select(node).attr('d', makeArrow(scale, op.pos, op.length, op.arrow));
} else {
// a rectangle (interior part of an alignment)
d3.select(node)
.attr({
'x': scale(op.pos + 1),
'height': READ_HEIGHT,
'width': scale(op.length) - scale(0)
});
}
break;

case CigarOp.DELETE:
// A thin line in the middle of the alignments indicating the deletion.
d3.select(node)
.attr({
'x1': scale(op.pos + 1),
'x2': scale(op.pos + 1 + op.length),
'y1': READ_HEIGHT / 2 - 0.5,
'y2': READ_HEIGHT / 2 - 0.5
});
break;

case CigarOp.INSERT:
// A thin vertical line. This is shifted to the left so that it's not
// hidden by the segment following it.
d3.select(node)
.attr({
'x1': scale(op.pos + 1) - 2, // to cover a bit of the previous segment
'x2': scale(op.pos + 1) - 2,
'y1': -1,
'y2': READ_HEIGHT + 2
});
break;

default:
throw `Invalid op! ${op.op}`;
}
}

// Should the Cigar op be rendered to the screen?
function isRendered(op) {
return (op.op == CigarOp.MATCH ||
op.op == CigarOp.DELETE ||
op.op == CigarOp.INSERT);
}

function readClass(vread: VisualAlignment) {
return 'alignment ' + (vread.strand == Strand.NEGATIVE ? 'negative' : 'positive');
}
Expand Down Expand Up @@ -169,26 +242,24 @@ class NonEmptyPileupTrack extends React.Component {
// Attach visualization info to the read and cache it.
addRead(read: SamRead, referenceSource): VisualAlignment {
var k = read.offset.toString();
var v = this.keyToVisualAlignment[k];
if (v) return v;
if (k in this.keyToVisualAlignment) {
return this.keyToVisualAlignment[k];
}

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 @@ -201,14 +272,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()
});
opInfo = getOpInfo(read, referenceSource);

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

Expand All @@ -222,7 +288,8 @@ class NonEmptyPileupTrack extends React.Component {

var referenceSource = this.props.referenceSource;
var vReads = this.state.reads.map(
read => this.addRead(read, referenceSource));
read => this.addRead(read, referenceSource))
.filter(read => read.refLength); // drop alignments w/o CIGARs

// Height can only be computed after the pileup has been updated.
var height = yForRow(this.pileup.length);
Expand All @@ -243,7 +310,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 @@ -254,13 +337,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
119 changes: 107 additions & 12 deletions src/pileuputils.js
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,112 @@ 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;
}

// The comments below come from the SAM spec
var CigarOp = {
MATCH: 'M', // alignment match (can be a sequence match or mismatch)
INSERT: 'I', // insertion to the reference
DELETE: 'D', // deletion from the reference
SKIP: 'N', // skipped region from the reference
SOFTCLIP: 'S', // soft clipping (clipped sequences present in SEQ)
HARDCLIP: 'H', // hard clipping (clipped sequences NOT present in SEQ)
PADDING: 'P', // padding (silent deletion from padded reference)
SEQMATCH: '=', // sequence match
SEQMISMATCH: 'X' // sequence mismatch
};

// 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
});

// These are the cigar operations which advance position in the reference
switch (op.op) {
case CigarOp.MATCH:
case CigarOp.DELETE:
case CigarOp.SKIP:
case CigarOp.SEQMATCH:
case CigarOp.SEQMISMATCH:
refPos += op.length;
}

// These are the cigar operations which advance the per-alignment sequence.
switch (op.op) {
case CigarOp.MATCH:
case CigarOp.INSERT:
case CigarOp.SOFTCLIP:
case CigarOp.SEQMATCH:
case CigarOp.SEQMISMATCH:
seqPos += op.length;
}

}

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

return {
ops: result,
mismatches
};
}

module.exports = {
pileup,
addToPileup,
getDifferingBasePairs
getOpInfo,
CigarOp
};
12 changes: 9 additions & 3 deletions style/pileup.css
Expand Up @@ -74,16 +74,22 @@
.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 {
stroke: rgb(97, 0, 216); /* matches IGV */
stroke-width: 2;
}
.pileup .delete {
stroke: black;
stroke-width: 2;
}

/* variants */
.variants {
Expand Down

0 comments on commit 22a999b

Please sign in to comment.