Scatterplot using axes that I saw in one of Edward Tufte’s books. I can’t remember which (maybe this one).
The axes are pretty pared down but provide basic distributional information of the data. The 1st and 4th quartiles are indicated by the outer lines and the 2nd and 3rd by the inner lines. The gap is the median.
Hovering allows you to see the actual value of a given point. Uses an invisible Voronoi tessellation to make point selection nicer (inspired by this block).
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Tufte Scatter</title>
<style>
html {
font-size: 12px;
font-family: monospace;
}
.refresh {
font-size: 12px;
font-family: monospace;
position: absolute;
top: 20px;
right: 160px;
}
.show-voronoi {
position: absolute;
top: 20px;
right: 50px;
}
.axis path {
fill: none;
stroke: #000;
stroke-width: 1px;
}
.voronoi {
fill: white;
stroke: #000;
stroke-width: .5px;
opacity: 0;
}
.voronoi.show {
opacity: .5;
}
.crosshair.line {
stroke: #000;
stroke-width: .5px;
stroke-dasharray: 2, 6;
}
</style>
</head>
<body>
<div class="container">
<button class="refresh">Change Data</button>
<label class="show-voronoi"><input type="checkbox">Show Voronoi</label>
</div>
<script src="//d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="tufte-axis.js"></script>
<script>
var margin = { top: 10, left: 50, bottom: 30, right: 10 },
width = 960 - margin.left - margin.right,
height = 500 - margin.top - margin.bottom;
var scale = {
x: d3.scale.linear().range([0, width]).nice(),
y: d3.scale.linear().range([height, 0]).nice()
};
var access = {
x: function(d) { return d.x; },
y: function(d) { return d.y; }
};
var value = {
x: function(d) { return scale.x(access.x(d)); },
y: function(d) { return scale.y(access.y(d)); }
};
var axis = {
x: tufteAxis().scale(scale.x).orient("bottom"),
y: tufteAxis().scale(scale.y).orient("left")
};
var svg = d3.select(".container").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 + ")");
svg.append("g").attr("class", "x axis");
svg.append("g").attr("class", "y axis");
var data = createData(100);
svg.call(renderPlot, data);
var refreshButton = d3.select("button.refresh")
.on("click", function() {
data = data.slice(0, 50).concat(createData(50));
svg.call(renderPlot, data);
});
var showVoronoiCheckbox = d3.select(".show-voronoi input")
.on("change", function() {
d3.selectAll(".voronoi")
.classed("show", this.checked);
});
function renderPlot(selection, data) {
updateScales(data);
axis.x.data(data.map(access.x));
axis.y.data(data.map(access.y));
selection.select(".x.axis").call(axis.x)
.attr("transform", "translate(0," + height + ")");
selection.select(".y.axis").call(axis.y);
selection
.call(renderVoronoi, data)
.call(renderPoints, data);
}
function renderVoronoi(selection, data) {
var voronoi = d3.geom.voronoi()
.x(value.x)
.y(value.y)
.clipExtent([[0, 0], [width, height]]);
var polygons = selection.selectAll(".voronoi")
.data(voronoi(data));
polygons.enter().append("path")
.attr("class", "voronoi")
.on("mouseenter", function(d, i) {
var datum = selection.selectAll(".point").data()[i];
selection.call(renderCrosshair, datum);
})
.on("mouseleave", function(d, i) {
selection.selectAll(".crosshair").remove();
});
polygons
.attr("d", d3.svg.line());
polygons.exit()
.remove();
}
function renderCrosshair(selection, datum) {
var lineData = [
// vertical line
[[value.x(datum), height],[value.x(datum), 0]],
// horizontal line
[[0, value.y(datum)],[width, value.y(datum)]]
];
var crosshairs = selection.selectAll(".crosshair.line").data(lineData);
crosshairs.enter().append("path")
.attr("class", "crosshair line");
crosshairs
.attr("d", d3.svg.line());
crosshairs.exit()
.remove();
var labelData = [
{
x: -6,
y: value.y(datum) + 4,
text: Math.round(access.y(datum)),
orient: "left"
},
{
x: value.x(datum),
y: height + 16,
text: Math.round(access.x(datum)),
orient: "bottom"
}
];
var labels = selection.selectAll(".crosshair.label").data(labelData);
labels.enter().append("text")
.attr("class", "crosshair label");
labels
.attr("x", function(d) { return d.x; })
.attr("y", function(d) { return d.y; })
.style("text-anchor", function(d) {
return d.orient === "left" ? "end" : "middle";
})
.text(function(d) { return d.text; });
labels.exit().remove();
}
function renderPoints(selection, data) {
var points = selection.selectAll(".point").data(data);
points.enter().append("circle")
.attr("class", "point")
.attr("cx", value.x)
.attr("cy", value.y)
.attr("r", 0)
.style("opacity", 0);
points
.transition().duration(1000)
.attr("cx", value.x)
.attr("cy", value.y)
.attr("r", 2)
.style("opacity", 1);
points.exit()
.transition().duration(1000)
.attr("r", 0)
.style("opacity", 0)
.remove();
}
function updateScales(data) {
var extent = {
x: d3.extent(data, access.x),
y: d3.extent(data, access.y)
};
scale.x.domain([extent.x[0] - 5, extent.x[1] + 5]);
scale.y.domain([extent.y[0] - 5, extent.y[1] + 5]);
}
function createData(n) {
return d3.range(0, n).map(function(i) {
var x = d3.random.normal(50, 10)(),
y = .25*Math.pow(x, 2) + .5*x + d3.random.normal(0, 250)();
return { x: x, y: y };
});
}
</script>
</body>
</html>
function tufteAxis() {
var scale, orient, data, q;
var medianGap = 5, // gap width where the median of data is
lineGap = 3; // gap between lines marking 1st and 3rd quartiles
var line = d3.svg.line();
function axis(g) {
var lineData = createLineData();
var paths = g.selectAll("path").data(lineData);
paths.enter().append("path");
paths
.transition().duration(1000)
.attr("d", line);
paths.exit().remove();
}
axis.scale = function(x) {
if (!arguments.length) return scale;
scale = x;
return axis;
};
axis.orient = function(x) {
if (!arguments.length) return orient;
orient = x;
return axis;
};
axis.data = function(x) {
if (!arguments.length) return data;
data = x;
q = getQuartiles(data);
return axis;
};
axis.medianGap = function(x) {
if (!arguments.length) return medianGap;
medianGap = x;
return axis;
};
axis.lineGap = function(x) {
if (!arguments.length) return lineGap;
lineGap = x;
return axis;
};
function getQuartiles(values) {
values.sort(function(a, b) { return a - b; });
var n = values.length - 1,
q = {};
[0, .25, .5, .75, 1].forEach(function(p) {
var i = Math.round(n*p);
q[p] = values[i];
})
return q;
}
function createLineData() {
if (orient === "bottom") {
return [
[
[scale(q[0]), lineGap],
[scale(q[.25]), lineGap]
],
[
[scale(q[.25]), 0],
[scale(q[.5]) - medianGap/2, 0]
],
[
[scale(q[.5]) + medianGap/2, 0],
[scale(q[.75]), 0]
],
[
[scale(q[.75]), lineGap],
[scale(q[1]), lineGap]
]
];
}
else if (orient === "left") {
return [
[
[0, scale(q[0])],
[0, scale(q[.25])]
],
[
[lineGap, scale(q[.25])],
[lineGap, scale(q[.5]) + medianGap/2]
],
[
[lineGap, scale(q[.5]) - medianGap/2],
[lineGap, scale(q[.75])]
],
[
[0, scale(q[.75])],
[0, scale(q[1])]
]
];
}
}
return axis;
}