swoopyDrag.js

Artisanal label placement for d3 graphics

“The annotation layer is the most important thing we do” —Amanda Cox

swoopyDrag helps you hand place annotations on d3 graphics. It takes an array of objects representing annotations and turns them into lines and labels. Drag the text and control circles below to update the annotations array:

The x and y functions are called on each annotation to determine its position. In the annotations array here, the sepalWidth and sepalLength properties are the data values of point the annotation refers to. The functions passed to x and y look up these values and encode them as pixel position using the same scale set up to position the circles.

var swoopy = d3.swoopyDrag()
  .x(d => xScale(d.sepalWidth))
  .y(d => yScale(d.sepalLength))
  .draggable(true)
  .annotations(annotations)

Setting draggable to true adds control points to the path strings and enables label dragging - turn on while developing and off when you're ready to publish.

The shape of each annotation's line is determined by the path property, the text by the text property and the position of the text by the testOffset property. Currently only straight paths (paths of the form M 0,0 L 10,10), béziers (paths of the form M 0,0 C 10,10 10,15, 20,15) and circular arcs are supported—see my interactive path string tutorial for more details.

The annotations are added to the page just like d3.svg.axis - append a new group element and use call:

var swoopySel = svg.append('g').call(swoopy)

After posititioning the labels, open the dev tools, run copy(annotations) in the console and paste over the old annotations array in your text editor.

Responsive

Since each annotation's position is determined primarily by scales, lines and labels will still point to the correct position when the chart size changes. As the chart shrinks though, the annotations might overlap or cover up data points. To show fewer or differently positioned labels on mobile, you could create multiple annotation arrays for different screen sizes:

d3.swoopyDrag()
  .annotations(innerWidth < 800 ? mobileAnnotations : desktopAnnotations)

Alternatively if there's just one or two problematic annotations that only work above or below some sizes, you could add maxWidth and minWidth properties to the overlapping annotations and filter:

d3.swoopyDrag()
  .annotations(annotations.filter(function(d){
    return (typeof(d.minWidth) == 'undefined' || innerWidth > d.minWidth)
        && (typeof(d.maxWidth) == 'undefined' || innerWidth < d.maxWidth)
    }))

Arrowheads

SVG has native support for arrowheads, but they can be a little fiddly to get working. First, add a marker element to the page the describes the shape of the arrow:

svg.append('marker')
    .attr('id', 'arrow')
    .attr('viewBox', '-10 -10 20 20')
    .attr('markerWidth', 20)
    .attr('markerHeight', 20)
    .attr('orient', 'auto')
  .append('path')
    .attr('d', 'M-6.75,-6.75 L 0,0 L -6.75,6.75')

Next, select paths in each annotation and set their marker-end attribute:

swoopySel.selectAll('path').attr('marker-end', 'url(#arrow)')

Text Wrap

Multiline text can be added with d3-jetpack. Select all of the text elements, clear the existing text, then use d3.wordwrap and tspans to wrap the text:

swoopySel.selectAll('text')
  .each(function(d){
    d3.select(this)
      .text('')                        //clear existing text
      .tspans(d3.wordwrap(d.text, 20)) //wrap after 20 char
  })  

Styling

On the page, annotations are made up of a group containing a path and a text element. Annotation data is bound to them, making it possible to control the style of individual annotations.

To customize the color of the text you could add a textColor property to the annotation data and use it to set the fill of the text:

swoopySel.selectAll('text').style('fill', d => d.textColor || '#000')

Or you could add a highlight class to some of the annotations:

swoopySel.selectAll('g').attr('class', d => d.class)

And emphasize them with css:

g.highlight text{ font-weight: 700; }
g.highlight path{ stroke-width: 2; }

Examples

d3-module-faces
Minute by Minute Point Differentials
NBA Win/Loss Records
Bush and Kasich Donors Give to Clinton

Other Tools

swoopyarrows creates fancier swoops, including circular and loopy arcs.
labella.js uses a force directed layout to position timeline labels with no overlap.
svg-crowbar lets you export a svg file and add annotations manually.
ai2html is an illustrator script that creates responsive html.

Contribute

github.com/1wheel/swoopy-drag