Skip to content

Instantly share code, notes, and snippets.

@asizer
Last active November 8, 2019 07:37
Show Gist options
  • Save asizer/11198007 to your computer and use it in GitHub Desktop.
Save asizer/11198007 to your computer and use it in GitHub Desktop.
d3 Horizontal BoxPlot

This sample is based on Mike Bostock's Box Plots. The box.js file has been modified in a number of ways besides making the box plots horizontal: the transitions have been removed, the 1.5 iqr function is included as the default to compute the whisker length, data objects are attached to the whisker ends and outlier dots (instead of just the values), and there are transparent q1-q2 and q2-q3 boxes that contain those respective data points for possible future use.

Hovering over outlier dots in the boxplot highlights them on the table and vice-versa. This also demonstrates a move-to-front functionality -- when an outlier circle is highlighted, it is moved to the end of the svg's elements so that it appears on top of the other outliers.

Also, the axis rounds its range to intervals that go into a power of 10 (see the cleanUpChartRange function).

The variable being plotted is a generated logNoraml distribution, to demonstrate outliers more prominently (sometimes there are so many, the table gets cut off -- sorry 'bout that).

/* global d3 */
/* jshint bitwise: false */
// Inspired by http://informationandvisualization.de/blog/box-plot
d3.box = function() {
var width = 1,
height = 1,
duration = 0,
domain = null,
value = Number,
whiskers = boxWhiskers,
quartiles = boxQuartiles,
outlierData = null,
tickFormat = null;
function box(g) {
g.each(function(d, i) {
// sort the data objects by the value function
d = d.sort(function(a, b) {
if (value(a) > value(b)) {
return 1;
}
if (value(a) < value(b)) {
return -1;
}
if (value(a) === value(b)) {
return 0;
}
});
var g = d3.select(this).attr('class', 'boxplot'),
justVals = d.map(value),
n = d.length,
min = justVals[0],
max = justVals[n - 1];
// Compute quartiles. Must return exactly 3 elements.
var quartileVals = justVals.quartiles = quartiles(justVals);
// Compute whiskers. Must return exactly 2 elements, or null.
var whiskerIndices = whiskers && whiskers.call(this, justVals, i),
whiskerData = whiskerIndices && whiskerIndices.map(function(i) {
return d[i];
});
// Compute outliers. If no whiskers are specified, all data are 'outliers'.
// The outliers are actual data objects, because I'm not concerned with transitions.
outlierData = whiskerIndices ?
d.filter(function(d, idx) {
return idx < whiskerIndices[0] || idx > whiskerIndices[1];
}) : d.filter(function() {
return true;
});
// Compute the new x-scale.
var xScale = d3.scale.linear()
.domain(domain && domain.call(this, justVals, i) || [min, max])
.range([0, width]);
// Note: the box, median, and box tick elements are fixed in number,
// so we only have to handle enter and update. In contrast, the outliers
// and other elements are variable, so we need to exit them!
// (Except this is a static chart, so no transitions, so no exiting)
// Update center line: the horizontal line spanning the whiskers.
var center = g.selectAll('line.center')
.data(whiskerData ? [whiskerData] : []);
center.enter().insert('line', 'rect')
.attr('class', 'center-line')
.attr('x1', function(d) {
return xScale(value(d[0]));
})
.attr('y1', height / 2)
.attr('x2', function(d) {
return xScale(value(d[1]));
})
.attr('y2', height / 2);
// whole innerquartile box. data attached is just quartile values.
var q1q3Box = g.selectAll('rect.q1q3box')
.data([quartileVals]);
q1q3Box.enter().append('rect')
.attr('class', 'box whole-box')
.attr('y', 0)
.attr('x', function(d) {
return xScale(d[0]);
})
.attr('height', height)
.attr('width', function(d) {
return xScale(d[2]) - xScale(d[0]);
});
// add a median line median line.
var medianLine = g.selectAll('line.median')
.data([quartileVals[1]]);
medianLine.enter().append('line')
.attr('class', 'median')
.attr('x1', xScale)
.attr('y1', 0)
.attr('x2', xScale)
.attr('y2', height);
// q1-q2 and q2-q3 boxes. attach actual data to these.
var q1q2Data = d.filter(function(d) {
return value(d) >= quartileVals[0] && value(d) <= quartileVals[1];
});
var q1q2Box = g.selectAll('rect.q1q2box')
.data([q1q2Data]);
q1q2Box.enter().append('rect')
.attr('class', 'box half-box')
.attr('y', 0)
.attr('x', function(d) {
return xScale(value(d[0]));
})
.attr('width', function(d) {
return xScale(value(d[d.length - 1])) - xScale(value(d[0]));
})
.attr('height', height);
var q2q3Data = d.filter(function(d) {
return value(d) > quartileVals[1] && value(d) <= quartileVals[2];
});
var q2q3Box = g.selectAll('rect.q2q3box')
.data([q2q3Data]);
q2q3Box.enter().append('rect')
.attr('class', 'box half-box')
.attr('y', 0)
.attr('x', function(d) {
return xScale(value(d[0]));
})
.attr('width', function(d) {
return xScale(value(d[d.length - 1])) - xScale(value(d[0]));
})
.attr('height', height);
// Whiskers. Attach actual data object
var whiskerG = g.selectAll('line.whisker')
.data(whiskerData || [])
.enter().append('g')
.attr('class', 'whisker');
whiskerG.append('line')
.attr('class', 'whisker')
.attr('x1', function(d) {
return xScale(value(d));
})
.attr('y1', height / 6)
.attr('x2', function(d) {
return xScale(value(d));
})
.attr('y2', height * 5 / 6);
whiskerG.append('text')
.attr('class', 'label')
.text(function(d) {
return Math.round(value(d));
})
.attr('x', function(d) {
return xScale(value(d));
});
whiskerG.append('circle')
.attr('class', 'whisker')
.attr('cx', function(d) {
return xScale(value(d));
})
.attr('cy', height / 2)
.attr('r', 3);
// Update outliers.
var outlierG = g.selectAll('g.outlier')
.data(outlierData)
.enter().append('g')
.attr('class', 'outlier');
outlierG.append('circle')
.attr('class', 'outlier')
.attr('r', 5)
.attr('cx', function(d) {
return xScale(value(d));
})
.attr('cy', height / 2);
outlierG.append('text')
.attr('class', 'label')
.text(function(d) {
return value(d);
})
.attr('x', function(d) {
return xScale(value(d));
});
});
}
box.width = function(x) {
if (!arguments.length) {
return width;
}
width = x;
return box;
};
box.height = function(x) {
if (!arguments.length) {
return height;
}
height = x;
return box;
};
box.tickFormat = function(x) {
if (!arguments.length) {
return tickFormat;
}
tickFormat = x;
return box;
};
box.duration = function(x) {
if (!arguments.length) {
return duration;
}
duration = x;
return box;
};
box.domain = function(x) {
if (!arguments.length) {
return domain;
}
domain = x == null ? x : d3.functor(x);
return box;
};
box.value = function(x) {
if (!arguments.length) {
return value;
}
value = x;
return box;
};
box.whiskers = function(x) {
if (!arguments.length) {
return whiskers;
}
whiskers = x;
return box;
};
box.quartiles = function(x) {
if (!arguments.length) {
return quartiles;
}
quartiles = x;
return box;
};
// just a getter. no setting outliers.
box.outliers = function() {
return outlierData;
};
return box;
};
function boxWhiskers(d) {
var q1 = d.quartiles[0],
q3 = d.quartiles[2],
iqr = (q3 - q1) * 1.5,
i = -1,
j = d.length;
while (d[++i] < q1 - iqr);
while (d[--j] > q3 + iqr);
return [i, j];
}
function boxQuartiles(d) {
return [
d3.quantile(d, 0.25),
d3.quantile(d, 0.5),
d3.quantile(d, 0.75)
];
}
<!DOCTYPE html>
<meta charset="utf-8">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>d3 Horizontal BoxPlot</title>
<script type="text/javascript" src="//d3js.org/d3.v3.min.js"></script>
<script type="text/javascript" src="horizontalboxplot.js"></script>
<link rel="stylesheet" href="main.css">
<script>
document.onreadystatechange = function () {
if (document.readyState == "complete") {
initChart();
}
};
function initChart() {
var dataArr = constructData();
var dataField = 'var3';
var chartRange = cleanUpChartRange(dataArr, dataField);
var totalWidth = 450,
totalHeight = 120,
margin = {
top: 30,
right: 30,
bottom: 50,
left: 30
},
width = totalWidth - margin.left - margin.right,
height = totalHeight - margin.top - margin.bottom;
var chart = d3.box()
.value(function(d) {
return d[dataField];
})
.width(width)
.height(height)
.domain([chartRange[0], chartRange[1]]);
var xScale = d3.scale.linear()
// this is the data x values
.domain([chartRange[0], chartRange[1]])
// this is the svg width
.range([0, width]);
var svg = d3.select('.svg-wrapper').selectAll('svg')
.data([dataArr])
.enter().append('svg')
.attr('width', totalWidth)
.attr('height', totalHeight)
.append('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')
.call(chart);
// axis
var xAxis = d3.svg.axis()
.scale(xScale)
.orient('bottom')
.ticks(10)
.tickFormat(tickFormatter);
// add axis
svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + (height + 20) + ')')
.call(xAxis);
buildOutlierTable(dataField, totalWidth);
}
function constructData() {
var numPoints = 300;
var max1 = 1958;
var max2 = 85731;
var log3 = 30;
// make sure the max value is in the data arr.
// this is just to test what happens on particular max values.
var arr1 = [max1];
for (var i = 0; i < numPoints - 1; i++) {
arr1.push(Math.floor(Math.random() * max1));
}
var arr2 = d3.range(numPoints).map(function() {
return d3.round(d3.random.normal(max2 / 2, max2 / 8)(), 1);
});
var arr3 = d3.range(numPoints).map(function() {
return d3.round(d3.random.logNormal(Math.log(log3), 0.4)(), 1);
});
return arr1.map(function(d, i) {
return {
myId: i,
var1: d,
var2: arr2[i],
var3: arr3[i]
};
});
}
function cleanUpChartRange(arr, field) {
// calculate the data's min and max so we can use it
// to make nice bin widths for the histogram
var xMin = d3.min(arr, function(dataObj) {
return dataObj[field];
});
var xMax = d3.max(arr, function(dataObj) {
return dataObj[field];
});
// construct nice bin widths
var rounderBin = 20;
var rounder;
var tempBinWidth = parseFloat((xMax / rounderBin).toFixed(2));
var multiplier;
var places = 2;
if (tempBinWidth < 1) {
multiplier = 0.1;
} else if (tempBinWidth < 2.6) {
multiplier = 1;
} else if (tempBinWidth < 10) {
multiplier = 5;
} else {
tempBinWidth = Math.round(tempBinWidth);
places = tempBinWidth.toString().length - 1;
multiplier = Math.pow(10, places);
}
rounder = Math.round(tempBinWidth / multiplier) * multiplier;
// clean up rounder so it goes evenly into a power of 10
if (multiplier > 10) {
while (Math.pow(10, places + 1) % rounder) {
rounder += multiplier;
}
}
// round xMax up to the nearest binWidth for the chart max
return [
Math.floor(xMin / rounder) * rounder,
Math.ceil(xMax / rounder) * rounder
];
}
function tickFormatter(d) {
if (d !== (d | 0)) {
// format non-integers as 1-decimal float
return d3.format('0.1f')(d);
} else if (d < 1000) {
// format just as integers
return d3.format('d')(d);
} else if (d < 10000 && (d % 1000 === 0)) {
// format using SI, to 1 significant digit
return d3.format('0.1s')(d);
} else {
// format using SI, to 2 significant digits
return d3.format('0.2s')(d);
}
}
function tableFormatter(d) {
if (isNaN(d)) {
return d;
}
if (d !== (d | 0)) {
// format non-integers as 1-decimal float
return d3.format('0,.1f')(d);
} else {
return d3.format(',d')(d);
}
}
function buildOutlierTable(dataField, width) {
var outlierCircles = d3.selectAll('g.outlier');
var outliersData = outlierCircles.data();
if (!outliersData || !outliersData.length) {
return;
}
var columns = [];
for (var keys in outliersData[0]) {
columns.push(keys);
}
var outliersWrapper = d3.select('.outliers-wrapper');
outliersWrapper.append('div').attr('class', 'outliers-title').text('Outliers by data field ' + dataField);
var outliersTable = outliersWrapper.append('table').attr('class', 'outliers-table').style('width', width + 'px'),
thead = outliersTable.append('thead'),
tbody = outliersTable.append('tbody');
// append the header row
thead.append('tr')
.selectAll('th')
.data(columns)
.enter()
.append('th')
.text(function(column) {
return column;
});
// create a row for each object in the data
var outlierRows = tbody.selectAll('tr')
.data(outliersData)
.enter()
.append('tr');
// create a cell in each row for each column
outlierRows.selectAll('td')
.data(function(row) {
return columns.map(function(column) {
return {
column: column,
value: row[column]
};
});
})
.enter()
.append('td')
.text(function(d) {
return tableFormatter(d.value);
});
// add highlighting to outlier hover
outlierCircles
.on('mouseover', function(circleD) {
outlierCircles.classed('highlight', false);
var currCircleG = d3.select(this).classed('highlight', true);
currCircleG.each(function() {
this.parentNode.appendChild(this);
});
outlierRows.classed('highlight', false);
outlierRows.filter(function(rowD) {
return circleD[dataField] === rowD[dataField];
}).classed('highlight', true);
})
.on('mouseleave', function() {
outlierRows.classed('highlight', false);
outlierCircles.classed('highlight', false);
});
outlierRows
.on('mouseover', function(rowD) {
outlierCircles.classed('highlight', false);
outlierCircles.filter(function(circleD) {
return circleD[dataField] === rowD[dataField];
}).classed('highlight', true)
.each(function() {
this.parentNode.appendChild(this);
});
})
.on('mouseleave', function() {
outlierCircles.classed('highlight', false);
});
}
</script>
</head>
<body>
<div class="svg-wrapper"></div>
<div class="outliers-wrapper-wrapper">
<div class="outliers-wrapper"></div>
</div>
</body>
</html>
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.svg-wrapper {
text-align: center;
}
.boxplot {
font-size: 10px;
}
/* boxplot shapes */
.boxplot line {
stroke: #1d3549;
stroke-width: 1px;
}
g.whisker circle,
.whole-box {
stroke: #396a93;
stroke-width: 1px;
fill: steelblue;
}
.half-box {
fill: white;
stroke: none;
fill-opacity: 0.1;
}
.center-line {
stroke-dasharray: 3,3;
}
g.whisker:hover line,
g.whisker:hover circle {
stroke-width: 2px;
}
/* boxplot outliers */
g.outlier circle {
fill: #fff;
stroke: #aaa;
fill-opacity: 0.2;
}
g.highlight circle {
fill: yellow;
stroke: #999;
fill-opacity: 1;
}
/* boxplot labels */
.label {
fill: #999;
text-anchor: middle;
opacity: 0;
}
g:hover > .label,
g.highlight > .label {
opacity: 1;
}
/* axis */
.axis path, .axis line {
fill: none;
stroke: #999;
stroke-width: 1px;
shape-rendering: crispEdges;
}
/* outlier table */
.outliers-wrapper-wrapper {
text-align: center;
}
.outliers-wrapper {
display: inline-block;
}
.outliers-title {
font-weight: bold;
text-align: center;
margin-bottom: 5px;
font-size: 16px;
}
.outliers-table {
border-collapse: separate;
border-spacing: 1px;
font-size: 12px;
}
.outliers-table th, .outliers-table td {
padding: 5px;
}
.outliers-table th {
background-color: #2b506e;
color: #eee;
}
.outliers-table tr:nth-child(even) {
background-color: #91b6d4 ;
}
.outliers-table tr:nth-child(odd) {
background-color: #dae7f1 ;
}
.outliers-table td {
text-align: center;
cursor: default;
}
.outliers-table tr.highlight,
.outliers-table tr:hover {
background-color: #FFFF66;
}
@carpiediem
Copy link

carpiediem commented Oct 3, 2017

Nice work. I was hoping to use this in a responsive design where the plot would need to change width if the window is resized. Do you have any suggestion on what would need to be in a box.refresh() function, if I added one? Do I need to delete the svg element and run the append/call function again? Or is it enough to edit the width attribute and run .call(chart) alone?

FYI, I've been trying to solve this myself; the delete and replace approach is easy enough, but I'm hoping for a more elegant solution...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment