See all blog posts ↑

Dashed Line Segmentation in D3.js

Aleksi Pekkala on

Dashed Line Segment D3.js

As everybody knows by now, D3 is a pretty nifty tool for creating interactive data visualizations: its simple yet powerful data binding API affords extraordinary flexibility for creating almost every kind of chart imaginable. The downside with this flexibility is, however, that solving some common visualization problems might take a bit more work than with your average high-level charting library.

One of these problems is implementing dashed lines in a line chart - a common way to indicate uncertainty in your data. D3, at least at the time of writing, does not provide an out-of-the-box feature for dashing lines. Fear not though, since in this article we’ll walk through a simple solution to dash your lines for days on end. For more details, check out the code at JSFiddle.

The Algorithm

In brief, we’ll figure out the dashed segments, calculate their lengths along the path element, and use these lengths to derive the stroke-dash-array property.

The first thing we need is a list of the dashed segments, namely their starting and ending indices. Utilizing lodash.reduce gives us a neat & functional implementation:

function getDashedRanges(data) {
  const hasOpenRange = (arr) => _.last(arr) && !('end' in _.last(arr))
  const lastIndex = data.length - 1

  return _.reduce(data, (res, d, i) => {
    const isRangeStart = !hasOpenRange(res) && isDashed(d)
    if (isRangeStart) res.push({ start: Math.max(0, i - 1) })

    const isRangeEnd = hasOpenRange(res) && (!isDashed(d) || i === lastIndex)
    if (isRangeEnd) res[res.length - 1].end = i

    return res
  }, [])
}

isDashed simply checks the certainty property, which I’ve added to each data item (faintly mimicking the Google Charts API):

function isDashed(d) {
  return !d.certainty
}

You’re free to come up with your own method of indicating uncertainty: return d.value !== 4 if you don’t trust the number 4, for example.

Next, we’ll need a list of path lengths at each data point. We’re using the getTotalLength and getPointAtLength methods from the SVG specs to define a new function, getPathLengthAtX. getPathLengthAtX basically approximates the length of a path element at given x coordinate:

function getPathLengthAtX(path, x) {
  const EPSILON = 1
  let point
  let target
  let start = 0
  let end = path.getTotalLength()

  // Mad binary search, yo
  while (true) {
    target = Math.floor((start + end) / 2)
    point = path.getPointAtLength(target)

    if (Math.abs(point.x - x) <= EPSILON) break

    if ((target >= end || target <= start) && point.x !== x) {
      break
    }

    if (point.x > x) {
      end = target
    } else if (point.x < x) {
      start = target
    } else {
      break
    }
  }

  return target
}

Then we’ll just put our state-of-the-art method to work, mapping it over the data:

const lengths = data.map(d => getPathLengthAtX(path, scales.x(d.date)))

Easy!

Finally, we’ll take the dashed segments and path lengths, and use them to build the stroke-dash-array property:

function buildDashArray(dashedRanges, lengths) {
  return _.reduce(dashedRanges, (res, { start, end }, i) => {
    const prevEnd = i === 0 ? 0 : dashedRanges[i - 1].end

    const normalSegment = lengths[start] - lengths[prevEnd]
    const dashedSegment = getDashedSegment(lengths[end] - lengths[start])

    return res.concat([normalSegment, dashedSegment])
  }, [])
}

For each non-dashed segment, append the length of the segment; for dashed segments, append dash & blank lengths totaling up to the segment length:

function getDashedSegment(length) {
  const totalDashLen = DASH_LENGTH + DASH_SEPARATOR_LENGTH
  const dashCount = Math.floor(length / totalDashLen)
  return _.range(dashCount)
    .map(() => DASH_SEPARATOR_LENGTH + ',' + DASH_LENGTH)
    .concat(length - dashCount * totalDashLen)
    .join(',')
}

Embedded Result

And there you go, dashed line segments! It took a bit of work, but we got there in the end. At this point one might ask, why not just build your path out of multiple line elements, i.e. adding separate elements for the dashed regions? That’s because the aforementioned method breaks interpolation. A more sophisticated hacker might use clip-paths to solve the dilemma; both solutions, however, force you to split your line into multiple elements which is, in most cases, undesirable (clip-paths are the way to go if you need something more advanced than dashed or blank segments, though).

Check out the code at JSFiddle.