In his influential data graphics text The Visual Display of Quantitative Information, Edward Tufte describes a chart form he calls the "dot-dash-plot" (p.133), which is similar to a scatter plot but also uses each of the chart axes to display a marginal distribution. This is a powerful extension which lets readers quickly interpret the concentration of values along each one-dimensional axis more readily than with the two-dimensional central plot alone.
There are many ways to implement this with D3.js, but one particularly concise and powerful approach is to write the distribution as a secondary chart function which is also an axis. The rendering function can both conform to the reusable charts pattern and also host additional proxy methods which fulfill the API of a d3-axis instance.
This project is written in a heavily annotated style called literate programming. The code blocks from this Markdown document are being executed as JavaScript by lit-web.
First, an anonymous function to guard the rest of the code in this script within a closure.
(() => {
Start with a bunch of static configuration values assigned to variables which we can then reference semantically throughout the rest of the script. In particular, it is helpful to add the size of the distribution to the margin convention.
const height = 480
const width = 960
const grid = 10
const radius = 2
const distributionSize = 10
const margin = {
top: grid * 3,
right: grid * 3,
bottom: grid * 3 + distributionSize,
left: grid * 3 + distributionSize
}
The same pair of scales can be used both for plotting the central chart and for the accompanying secondary distributions.
const x = d3.scaleLinear()
.domain([0, 1])
.range([0, width - margin.left - margin.right])
const y = d3.scaleLinear()
.domain([0, 1])
.range([height - margin.top - margin.bottom, 0])
Generate some data points to plot.
const xValue = d3.randomNormal(0.45, 0.12)
const yValue = d3.randomNormal(0.6, 0.1)
const data = Array.from({length: 500})
.map(() => {
return {
x: xValue(),
y: yValue()
}
})
This factory returns a distribution plotting function which also internally manages a D3 axis.
In the interest of simplicity, the function signature of the factory as implemented here requires the caller to specify either horizontal or vertical alignment. That helps keep the code in this demonstration concise, but if we wanted to make it a little more verbose, this could also be configured as a set of four drop-in replacements for d3.axisTop
, d3.axisBottom
, d3.axisLeft
, and d3.axisRight
.
The hybrid axis also obviously needs access to the complete data set in order to plot it.
const distributionAxis = (dimension, data) => {
let scale
const axis = dimension === 'x' ? d3.axisBottom() : d3.axisLeft()
We'll refer to the return value of the factory as the hybrid
because from the purposes of the calling code it is simultaneously operating as both a chart and an axis.
const hybrid = selection => {
// plot the distribution
const yOffset = dimension === 'x' ? distributionSize * -1 : 0
const scale = axis.scale()
selection.append('g')
.classed('distribution', true)
.attr('transform', `translate(0,${yOffset})`)
.selectAll('.mark')
.data(data)
.enter()
.append('g')
.classed('mark', true)
.append('rect')
.attr('x', d => dimension === 'x' ? scale(d.x) : 0)
.attr('y', d => dimension === 'y' ? scale(d.y) : 0)
.attr('height', dimension === 'x' ? distributionSize : 1)
.attr('width', dimension === 'y' ? distributionSize : 1)
// render the axis
selection.append('g')
.classed('axis', true)
.call(axis)
}
Starting with an instance of d3.axisTop
as the example to mock against, we now proxy all the axis configuration methods onto the hybrid function. Whenever any axis method is called on the hybrid object, it instead calls the corresponding method from its internal D3 axis.
The proxy methods examine the return value of the equivalent axis method in order to determine their own return behavior. If the memory pointer reveals that the axis method is just trying to return itself to facilitate fluent chaining, the proxy method on the hybrid object will do the same, returning the hybrid. On the other hand, if there's a difference between the return value and the axis object, that indicates that the method is being used as a getter, which means method chaining is not a concern and the result of the getter should be returned.
You could, of course, further instrument this step with all sorts of other nonsense!
Object.keys({ ...d3.axisTop() }).forEach(key => {
hybrid[key] = (...args) => {
const result = axis[key](...args)
return result === axis ? hybrid : result
}
})
Return the hybrid and close out the factory function.
return hybrid
}
What the hell, let's staple the factory to the d3
object. This doesn't accomplish anything technically other than a sort of namespacing, but maybe it's worth doing nonetheless just to syntactically drive home the point that this could be considered an extension of the core chart logic and is equivalent to the built-in axis functions.
d3.distributionAxis = distributionAxis
Now let's see it in action! This next chunk of code just renders a typical scatter plot with the distributionAxis
in place of the usual calls to d3.axisLeft
and d3.axisBottom
.
const dotdashplot = selection => {
const wrapper = selection
.append('g')
.classed('wrapper', true)
.attr('transform', `translate(${margin.left},${margin.top})`)
const points = wrapper
.append('g')
.classed('points', true)
const point = points
.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr('cx', d => x(d.x))
.attr('cy', d => y(d.y))
.attr('r', radius)
.classed('point', true)
const axes = wrapper
.append('g')
.classed('axes', true)
// render axes with the distributionAxis
// instead of d3.axisLeft and d3.axisBottom
axes
.append('g')
.attr('class', 'distribution-axis y-axis')
.call(d3.distributionAxis('y', data).scale(y))
const yOffset = y.range()[0] - y.range()[1]
axes
.append('g')
.attr('class', 'distribution-axis x-axis')
.attr('transform', `translate(0,${yOffset})`)
.call(d3.distributionAxis('x', data).scale(x))
}
Render the chart – or all three charts, I should say.
d3.select('main')
.html('')
.append('svg')
.attr('height', height)
.attr('width', width)
.call(dotdashplot)
Close the anonymous function opened at the very beginning.
})()