I recently created an oncoprint for a client using D3.js. Oncoprints are gene mutation visualizations popularized by cBioPortal and used widely to present oncology research. There are a few R packages that make static oncoprints, but none seem optimized for shiny applications. Similarly there are a handful of standalone javascript packages to create oncoprints (canvasXpress, D3Oncoprint, and even oncoprintjs), but each lacked something critical (some lacked simplicity). I developed this one specifically to use with the r2d3 package. That should explain some of the extraneous code, especially around setting the svg size, although it will be clear that this is my first D3 visualization. I'll post more on adapting this to a r2d3 script in the future.
There are a few features to highlight: each sample gets a tooltip, and users can zoom with their scrollwheel. Brushing is available at the top and bottom of the plot to select groups of samples. Right clicking the brushed region brings up a context menu, as does right clicking each gene -- although actions for the context menu are disabled outside of shiny.
Most of what makes an oncoprint is how the samples are sorted. This version relies on R to do the sorting prior to passing the data to D3. I might update that in the future. In a shiny app, clicking a gene will sort the plot by that gene.
Thanks to Yan Holtz for publishing some heatmap code that started me on the right foot with this project.
<!DOCTYPE html>
<meta charset="utf-8">
<head>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script
src="https://code.jquery.com/jquery-3.3.1.min.js"
integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8="
crossorigin="anonymous">
</script>
<script>
$('html').click(function(e) {
$('.genecontext-menu').css('opacity', 0).css('z-index', '-1');
$('.brushcontext-menu').css('opacity', 0).css('z-index', '-1');
});
</script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css" integrity="sha384-PDle/QlgIONtM1aqA2Qemk5gPOE7wFq8+Em+G/hmo5Iq0CCmYZLv3fVRDJ4MMwEA" crossorigin="anonymous">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.0/css/bootstrap.min.css" integrity="sha384-PDle/QlgIONtM1aqA2Qemk5gPOE7wFq8+Em+G/hmo5Iq0CCmYZLv3fVRDJ4MMwEA" crossorigin="anonymous">
<style>
#oncoprint {
width: 1100px;
margin: auto;
}
.genecontext-menu:hover {
background-color: #bfbfbf !important;
color: #333 !important;
border: 1px #333 solid;
}
.brushcontext-menu li:hover {
background-color: #bfbfbf !important;
color: #333 !important;
border: 1px #333 solid;
}
.brushcontext-menu ul {
list-style: none;
margin-bottom: 0px;
padding: 0px;
}
.brushcontext-menu li {
padding: 0px 5px;
}
.outer-holder .brushed. .selection {
opacity: 0.5;
}
.tooltip:after {
right: 100%;
top: 25%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-color: rgba(255, 255, 255, 0);
border-right-color: rgba(51, 51, 51, 0.9);
border-width: 10px;
margin-top: -10px;
}
.tooltip .lab {
padding-right:0.6em; text-align:right;
}
body {
width: 100%;
min-height: 100%;
}
</style>
</head>
<body>
<!-- Create a div where the graph will take place -->
<div id="oncoprint"></div>
<script>
// set the dimensions and margins of the graph
var origmargin = {top: 10, right: 10, bottom: 10, left: 10},
width = 1000 - origmargin.left - origmargin.right,
height = 250 - origmargin.top - origmargin.bottom;
// set the dimensions and margins of the graph
var margin = {top: 5, right: 285, bottom: 30, left: 40},
newwidth = width - margin.left - margin.right,
newheight = height - margin.top - margin.bottom;
// the colors in the legend and the barplot are pulled from here
//// this could (should) come from the data
var alterations = [
{name:"High level amplification", color:"#8B0000", type:"Amp"},
{name:"Low level gain", color:"#ff5733", type:"Gain"},
{name:"Shallow deletion", color:"#00b8ff", type:"HetLoss"},
{name:"Deep deletion", color:"#00008B", type:"HomDel"},
{name:"Mutation", color:"#169e35", type:"Mutation"},
{name:"Fusion", color:"#ffc125", type:"Fusion"}
];
// append the svg object to the body of the page
var svg = d3.select("#oncoprint")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform",
"translate(" + margin.left + "," + margin.top + ")");
//Read the data
d3.csv("https://raw.githubusercontent.com/jonkatz2/jonkatz2.github.io/master/assets/blog/oncoprint/oncodata.csv").then(function(data) {
var g = svg.append("g")
.attr("class", "outer-holder")
.attr("transform", "translate(" + margin.left + "," + (margin.top+45) + ")");
// get unique values of models and genes
var myGroups = d3.map(data, function(d){return d.group;}).keys()
var myVars = d3.map(data, function(d){return d.variable;}).keys()
// use a fixed bar height until the box is full, then compress it
newheight = Math.min(newheight, 31 * myVars.length);
var plotArea = g.append("g")
.attr("clip-path", "url(#oncoprintAreaClip)");
// update: set width and height of clippath rect
plotArea.append("clipPath")
.attr("id", "oncoprintAreaClip")
.append("rect")
.attr('width', newwidth)
.attr('height', newheight);
// Build X scales and axis:
var x = d3.scaleLinear()
.range([ 0, newwidth ])
.domain([0, myGroups.length]);
// Build Y scales and axis:
var y = d3.scaleBand()
.range([ newheight, 0 ])
.domain(myVars)
.padding(0.05);
g.append("g")
.attr("class", "y1-axis")
.style("font-size", "1em")
.call(d3.axisLeft(y).tickSize(0))
.select(".domain").remove();
// create a context menu for the gene right-click
d3.select("body").selectAll(".genecontext-menu").remove();
var genemenu = d3.select("body")
.append("div")
.style("opacity", 0)
.attr("class", "genecontext-menu")
.attr("data-toggle", "modal")
.attr("data-target", "#lollipopmodal")
.style("background-color", "rgb(51, 51, 51, 0.9)")
.style("color", "#FFF")
.style("padding", "5px")
.style("position", "absolute")
.style("z-index", "-1");
// show the gene context menu on right-click and feed it data
var showgenecontext = function(d) {
genemenu
.html("Lollipop plot for "+ d)
.style("opacity", 1)
.style("z-index", "10000")
.style("left", (d3.event.pageX + 15) + "px")
.style("top", (d3.event.pageY - 15) + "px")
// .on("click", function (i) {
// Shiny.setInputValue("mutation-lolliplot_gene", d, {priority: "event"});
// })
}
// this selection group contains model names selected by brushing
var selgrp = [];
// create a context menu for the brush right-click
d3.select("body").selectAll(".brushcontext-menu").remove();
var brushmenu = d3.select("body")
.append("div")
.style("opacity", 0)
.attr("class", "brushcontext-menu")
.style("background-color", "rgb(51, 51, 51, 0.9)")
.style("color", "#FFF")
// .style("padding", "5px")
.style("position", "absolute")
.style("z-index", "-1");
// the ul container
var brushbuttons = brushmenu.append("ul")
.style("list-style", "none")
.style("margin-bottom", "0px")
.style("padding", "0px")
var brushlist = brushbuttons.selectAll("li")
.data(["Add selection to cart", "Replace cart with selection"])
brushlist.exit().remove()
brushlist.enter().append("li")
.html(function(d) {return d;})
.style("padding", "3px 5px")
.attr("class", function(d, i) { return "brushitem"+i })
// show the brush context menu on right-click
var showbrushcontext = function(d) {
// turn off any prior click events
$(".brushitem0").off("click");
$(".brushitem1").off("click");
// un-hide the brush menu
brushmenu
.style("opacity", 1)
.style("z-index", "10000")
.style("left", (d3.event.pageX + 15) + "px")
.style("top", (d3.event.pageY - 15) + "px");
// // set the click event using jQuery
// $(".brushitem1").click(function() {
// Shiny.setInputValue("mutation-oncoprintselectionreplace", selgrp, {priority: "event"})
// });
// $(".brushitem0").click(function() {
// Shiny.setInputValue("mutation-oncoprintselectionadd", selgrp, {priority: "event"})
// });
}
// Add links to the gene/y-axis
g.selectAll(".y1-axis").selectAll('.tick')
.data(myVars)
.attr("gene", function(d) { return d })
// .on('click', function (d) {
// zoom.transform(oncoprint, d3.zoomIdentity);
// Shiny.setInputValue("mutation-gene_priority", d, {priority: "event"});
// })
.on("contextmenu", function (d, i) {
d3.event.preventDefault()
showgenecontext(d)
})
.exit();
// remove all tooltips
d3.select("body").selectAll(".oncoprinttooltip").remove();
// create a tooltip
var tooltip = d3.select("body")
.append("div")
.style("opacity", 0)
.attr("class", "oncoprinttooltip")
.style("background-color", "rgb(51, 51, 51, 0.9)")
.style("color", "#FFF")
.style("padding", "5px")
.style("position", "absolute");
// Three functions that change the tooltip when user hover / move / leave a cell
var mouseover = function(d) {
tooltip
.style("opacity", 1)
.style("z-index", 1000)
d3.select(this)
.style("stroke", "black")
.style("opacity", 1)
}
var mousemove = function(d) {
if (d.mutation == "") {var mut = ""}
else {var mut = "<tr><td class='lab' style='vertical-align:top;'>Mutation</td><td>" + d.mutation + "</td></tr>"}
tooltip
.html("<table><tr><td class='lab'>Sample</td><td>" + d.group + "</td></tr><tr><td class='lab'>Gene</td><td>" + d.variable + "</td></tr><tr><td class='lab'>Alteration</td><td>" + d.value + "</td></tr>" + mut + "</table>")
.style("left", (d3.event.pageX + 15) + "px")
.style("top", (d3.event.pageY - 15) + "px")
}
var mouseleave = function(d) {
tooltip
.style("opacity", 0)
.style("z-index", -1)
d3.select(this)
.style("stroke", "none")
.style("opacity", 0.8)
}
// brush region under oncoprint, extends top and bottom for area to grab
var brush = d3.brushX()
.extent([[-10, -20], [newwidth+10, newheight+20]])
.on("start", brushstart)
.on("brush", brushing)
.on("end", brushend);
var brushgroup = g.append("g")
.attr("class", "brushed")
.on("contextmenu", function (d) {
d3.event.preventDefault()
showbrushcontext()
})
.call(brush);
// zoom region exactly matches the oncoprint area
var zoom = d3.zoom()
.scaleExtent([1, Infinity])
.translateExtent([[0, 0], [newwidth, newheight]])
.extent([[0, 0], [newwidth, newheight]])
.on("zoom", zooming)
// .on("end", zoomend);
// record the alterations per gene as we draw the rectangles
var altcount = {};
myVars.forEach(function(d) {
altcount = {...altcount, [d]:{Amp:0,Gain:0,HetLoss:0,HomDel:0,Fusion:0,Mutation:0}};
});
// record the samples with alterations per gene as we draw the rectangles
var samplecount = {};
myVars.forEach(function(d) {
samplecount = {...samplecount, [d]:[]};
});
var oncoprint = g.append("g")
.attr("class", "oncoprint")
.call(zoom);
// .on("dblclick.zoom", null)
// .call(brush);
// add the primary squares
var alterationrect = oncoprint.selectAll()
.data(data)
.enter()
.append("rect")
.attr("class", "cna")
.attr("x", function(d) { return x(myGroups.indexOf(d.group)); })
.attr("y", function(d) { return y(d.variable) })
.attr("width", function(d) { return (x(myGroups.indexOf(d.group)+0.9) - x(myGroups.indexOf(d.group))); })
.attr("height", y.bandwidth() )
.style("fill", function(d) {
if (d.value == "Amp") {altcount[d.variable].Amp += 1; samplecount[d.variable].push(d.group); return "#8B0000"}
else if (d.value == "Gain" ) {altcount[d.variable].Gain += 1; samplecount[d.variable].push(d.group); return "#ff5733"}
else if (d.value == "HetLoss") {altcount[d.variable].HetLoss += 1; samplecount[d.variable].push(d.group); return "#00b8ff"}
else if (d.value == "HomDel") {altcount[d.variable].HomDel += 1; samplecount[d.variable].push(d.group); return "#00008B"}
else { return "#D3D3D3" }
})
.style("stroke-width", 4)
.style("stroke", "none")
.style("opacity", 0.8)
.on("mouseover", mouseover)
.on("mousemove", mousemove)
.on("mouseleave", mouseleave)
.exit()
// add the mutation squares
var mutationrect = oncoprint.selectAll()
.data(data)
.enter()
.append("rect")
.attr("class", "mut")
.attr("x", function(d) { return x(myGroups.indexOf(d.group)); })
.attr("y", function(d) { return y(d.variable) })
.attr("width", function(d) { return (x(myGroups.indexOf(d.group)+0.9) - x(myGroups.indexOf(d.group))); })
.attr("height", function(d) {
if (d.mutation == "") { return 0 }
else { return y.bandwidth()/3 }
})
.attr("transform", "translate(0," + y.bandwidth()/3 + ")")
.style("fill", function(d) {
if (d.mutation == "") {return "transparent"}
else if (d.mutation.match(/Fusion/) != null) {altcount[d.variable].Fusion += 1; samplecount[d.variable].push(d.group); return "#ffc125"}
else { altcount[d.variable].Mutation += 1; samplecount[d.variable].push(d.group); return "#169e35" }
})
.on("mouseover", mouseover)
.on("mousemove", mousemove)
.on("mouseleave", mouseleave)
.exit()
// this brush fn before any zooming
function brushing() {
selgrp = [];
if (d3.event.sourceEvent.type === "brush") return;
var selection = d3.event.selection;
if (selection == null) {
selgrp = [];
} else {
var x0 = Math.round(x.invert(selection[0])),
x1 = Math.round(x.invert(selection[1]));
// snap to boundaries
d3.select(this).call(d3.event.target.move, [x(x0), x(x1+0.9)]);
myGroups.forEach(function (d, i) {
if (x0 <= i && i <= x1)
selgrp.push(d);
});
}
}
// clear the selectd models before the next selection
function brushstart() {
selgrp = [];
}
function zooming() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var rescaleX = d3.event.transform.rescaleX(x);
// confine the brush selection on zoom
brush = d3.brushX()
.extent([[-10, -20], [newwidth+10, newheight+20]])
.on("start", brushstart)
.on("brush", brushing)
.on("end", brushend);
brushgroup.call(brush);
// this brush fn is used after zooming
function brushing() {
selgrp = [];
if (d3.event.sourceEvent.type === "brush") return;
var selection = d3.event.selection;
if (selection == null) {
selgrp = [];
} else {
var x0 = Math.round(rescaleX.invert(selection[0])),
x1 = Math.round(rescaleX.invert(selection[1]));
// snap to boundaries
d3.select(this).call(d3.event.target.move, [rescaleX(x0), rescaleX(x1+0.9)]);
myGroups.forEach(function (d, i) {
if (x0 <= i && i <= x1)
selgrp.push(d);
});
}
}
// maintain brush position on zoom
g.select(".brushed").call(brush.move, function(d) {
if(selgrp.length) {
var selection = d3.event.selection;
var x0 = rescaleX(myGroups.indexOf(selgrp[0])),
x1 = rescaleX(myGroups.indexOf(selgrp[selgrp.length-1]));
x0 = Math.max(x0, 0);
x1 = Math.min(x1, newwidth);
// snap to boundaries
return [x0, x1];
}
});
// redraw rectangles on zoom
oncoprint.selectAll("rect.cna")
.attr('clip-path', 'url(#oncoprintAreaClip)')
.attr("x",function(d){ return rescaleX(myGroups.indexOf(d.group)); })
.attr("y",function(d){ return y(d.variable); })
.attr('width', function(d) { return (rescaleX(myGroups.indexOf(d.group)+0.9)-rescaleX(myGroups.indexOf(d.group))); })
.attr("height", function(d) { return y.bandwidth(); })
.style("fill", function(d) {
if (d.value == "Amp") {altcount[d.variable].Amp += 1; return "#8B0000"}
else if (d.value == "Gain" ) {altcount[d.variable].Gain += 1; return "#ff5733"}
else if (d.value == "HetLoss") {altcount[d.variable].HetLoss += 1; return "#00b8ff"}
else if (d.value == "HomDel") {altcount[d.variable].HomDel += 1; return "#00008B"}
else { return "#D3D3D3" }
})
oncoprint.selectAll("rect.mut")
.attr('clip-path', 'url(#oncoprintAreaClip)')
.attr("x",function(d){ return rescaleX(myGroups.indexOf(d.group)); })
.attr("y",function(d){ return y(d.variable); })
.attr('width', function(d) { return (rescaleX(myGroups.indexOf(d.group)+0.9)-rescaleX(myGroups.indexOf(d.group))); })
.attr("height", function(d) {
if (d.mutation == "") { return 0 }
else { return y.bandwidth()/3 }
})
.attr("transform", "translate(0," + y.bandwidth()/3 + ")")
.style("fill", function(d) {
if (d.mutation == "") {return "#transparent"}
else if (d.mutation.match(/Fusion/) != null) {altcount[d.variable].Fusion += 1; return "#ffc125"}
else { altcount[d.variable].Mutation += 1; return "#169e35" }
})
// zoom status
svg.select(".zoom-status").remove()
svg.append("text")
.attr("class", "zoom-status")
.attr("x", 25)
.attr("y", 25)
.attr("text-anchor", "left")
.style("font-size", "0.9em")
.style("fill", "#333;")
.style("max-width", newwidth/2)
.text("Zoom: " + Math.round(rescaleX(myGroups.length-1)/newwidth*10)/10 + "x");
}
// send the brushed selection to the DOM element on zoomend --
function brushend() {
if (selgrp.length) {
// don't resend on zoom
if (d3.event.sourceEvent.type == "zoom") return;
// Shiny.setInputValue("mutation-brush_selection", selgrp, {priority: "event"});
}
}
// the stacked barplot of total alterations by type //
// tooltip shows the count of the color-within-bar on hover
var barmousemove = function(d) {
tooltip
.html((d[1]-d[0]))
.style("left", (d3.event.pageX + 15) + "px")
.style("top", (d3.event.pageY - 15) + "px")
}
// convert the alteration counter to an array
var altmat = []
myVars.forEach(function(d) {
var temp = altcount[d]
temp["name"] = d;
altmat.push(temp);
});
// make a barplot area to the right of the percentages
var barplot = g.append("g")
.attr("transform", "translate(" + (newwidth+35) + ",0)")
.attr("class", "barplot")
// stack bars
var barstack = d3.stack()
.keys(["Amp", "Gain", "HetLoss", "HomDel", "Mutation", "Fusion"])
.order(d3.stackOrderNone)
.offset(d3.stackOffsetNone);
// convert stacked bars to series-based array
var barseries = barstack(altmat);
// identify the highest alteration count in any gene
var altctmax = d3.max(barseries, function(d) {return d3.max(d, function(j) { return +j[1];});});
//var altctmax = d3.max(d3.max(barseries), function(d) {return d[1];});
// Scale the barplot to fit in the alotted 65px
var bary = d3.scaleLinear()
.domain([0, altctmax])
.range([0, 65]);
// add an axis
barplot.append("g")
.attr("transform", "translate(0,-1)")
.style("font-size", 6)
.call(d3.axisTop(bary).ticks(3).tickSize(3).tickValues([0, Math.round(altctmax/2), altctmax]))
.select(".domain").remove()
// Draw the barplot rectangles
barplot.selectAll()
.data(barseries)
.enter().append("g")
.attr("class", "serie")
.attr("fill", function(d, i) { return alterations[i].color; })
.selectAll()
.data(function(d) { return d; })
.enter().append("rect")
.attr("x", function(d) { return bary(d[0]); })
.attr("y", function(d, i) { return y(myVars[i]); })
.attr("width", function(d) { return bary((d[1] - d[0])); })
.attr("height", y.bandwidth())
.on("mouseover", mouseover)
.on("mousemove", barmousemove)
.on("mouseleave", mouseleave);
// plot title
svg.select(".title").remove()
svg.append("text")
.attr("class", "title")
.attr("x", newwidth/2)
.attr("y", 25)
.attr("text-anchor", "left")
.style("font-size", "1.1em")
.style("fill", "#333;")
.style("max-width", width)
.text(myGroups.length + " Samples");
// the second y axis is not a real axis because the values may not be unique
var y2 = g.append("g")
.attr("transform", "translate(" + newwidth + ",0)")
.attr("class", "y2-axis")
.style("font-size", "0.9em");
// labels
y2.selectAll()
.data(myVars)
.enter()
.append("text")
.style("font-size", "0.7em")
.attr("x", 3)
.attr("y", function(d) { return y(d) + y.bandwidth()/2 + 2 })
.text(function(d) { return Math.round([...new Set(samplecount[d])].length/myGroups.length*100) + "%" })
.exit()
// data out
})
//// add a legend
// group the legend
svg.selectAll(".legend").remove()
var legend = svg.append("g")
.attr("transform", "translate(" + (newwidth+margin.left+70+45) + "," + (margin.top+45) + ")")
.attr("class", "legend")
// legend title
legend.append("text")
.attr("x", 0)
.attr("y", 10)
.style("font-size", "0.9em")
.text("Alterations")
.exit();
// colored rects
legend.selectAll()
.data(alterations)
.enter()
.append("rect")
.attr("x", 0)
.attr("y", function(d, i) { return (16 * i + 20) })
.attr("width", 8 )
.attr("height", 14 )
.style("fill", function(d) { return d.color })
.exit();
// labels
legend.selectAll()
.data(alterations)
.enter()
.append("text")
.style("font-size", "0.8em")
.attr("x", 16)
.attr("y", function(d, i) { return (16 * i + 31) })
.text(function(d) { return d.name })
.exit()
</script>
</body>