Skip to content

Instantly share code, notes, and snippets.

@vijithassar
Last active May 25, 2020 23:32
Show Gist options
  • Save vijithassar/30c5c0f6aafa5d68e892ad58b68ff496 to your computer and use it in GitHub Desktop.
Save vijithassar/30c5c0f6aafa5d68e892ad58b68ff496 to your computer and use it in GitHub Desktop.
dot-dash-plot

Overview

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.

Implementation

Wrapper

First, an anonymous function to guard the rest of the code in this script within a closure.

(() => {

Configuration

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
  }

Scales

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])

Data

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()
      }
    })

Distribution Axis

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)

  }

Axis Methods

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
}

D3 Method

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

Chart

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

Execute

Render the chart – or all three charts, I should say.

  d3.select('main')
    .html('')
    .append('svg')
    .attr('height', height)
    .attr('width', width)
    .call(dotdashplot)

Fin

Close the anonymous function opened at the very beginning.

})()
<html>
<head>
<script type="text/javascript" src="https://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript" src="https://unpkg.com/lit-web"></script>
<link type="text/css" href="style.css" rel="stylesheet" />
</head>
<body>
<main>
</main>
<script type="text/markdown" src="./README.md"></script>
</body>
</html>
/* use opacity to mitigate overplotting occlusion */
circle {
opacity: 0.3;
}
rect {
opacity: 0.25;
}
g.axis g.tick:first-of-type, g.axis path {
display: none;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment