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(function(d){ return xScale(d.sepalWidth) })
    .y(function(d){ return yScale(d.sepalLength) })

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.


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:

  .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:

    return (typeof(d.minWidth) == 'undefined' || innerWidth > d.minWidth)
        && (typeof(d.maxWidth) == 'undefined' || innerWidth < d.maxWidth)


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:

    .attr('id', 'arrow')
    .attr('viewBox', '-10 -10 20 20')
    .attr('markerWidth', 20)
    .attr('markerHeight', 20)
    .attr('orient', 'auto')
    .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:

          .text('')                        //clear existing text
          .tspans(d3.wordwrap(d.text, 20)) //wrap after 20 char

Since the annotations are made up of selectable path and group elements, they can be styled differently.


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 illustrator script that creates responsive html.