Skip to content

Instantly share code, notes, and snippets.

@vijithassar
Last active May 25, 2020 15:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vijithassar/405130b3efe978d005c0ce6516da670c to your computer and use it in GitHub Desktop.
Save vijithassar/405130b3efe978d005c0ce6516da670c to your computer and use it in GitHub Desktop.
Voronoi-free mouseover snapping

Overview

Detailed graphics present a challenge for hover interactions because small target areas corresponding to individual data points are hard for the user to hit, making it difficult to access additional data revealed through mouseovers. One solution can be to increase the size of the targets such that effectively everything becomes a target, and it is assumed that the user's intention was to hit whichever target is closest to the mouse. The two scatter plots here illustrate the difference between these two approaches; it's certainly much easier to navigate the second.

One popular way to implement this kind of interaction is by overlaying a Voronoi diagram, turning each point into a polygon for the purposes of capturing user interactions. Nadieh Brehmer explains this technique in detail.

However, using a Voronoi diagram significantly complicates the markup of an SVG, which then essentially needs to contain a secondary DOM which serves as the interaction layer. If this rubs you the wrong way, it is also possible to arrive at the same behavior using using computations entirely in memory, without any rendering artifacts. That's how this second example works.

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.

  const min = 0
  const max = 1000
  const height = 300
  const width = 300
  const grid = 20
  const radius = 3
  const margin = {
    top: grid,
    right: grid,
    bottom: grid,
    left: grid
  }
  let data

Scales

Scales are among those shared values. For a typical graphic these would probably be inside the chart() function, but for this technique they're needed in order to calculate the interaction target so they have been moved to the outer scope.

  const x = d3.scaleLinear()
    .domain([0, max])
    .range([margin.left, width])
  const y = d3.scaleLinear()
    .domain([max, 0])
    .range([margin.bottom, height])

Tooltip

A simple tooltip function which prints the values on top of the data point. This obviously isn't particularly useful since it just replicates the information from the chart axes, but the point of this exercise is to change how this function is triggered by user interactions.

  const tooltip = (wrapper, circle) => {
    const { x, y } = circle.node().getBBox()
    const text = wrapper.select('text.tooltip')
    text
      .attr('transform', `
        translate(
          ${x + radius},
          ${y}
        )
      `)
    text
      .text(`
        ${circle.datum().x}
        ×
        ${circle.datum().y}
      `)
  }

Debounce

One of the primary costs of this in-memory technique is that it tracks all mouse movement, not the borders between Voronoi areas. Your browser can efficiently track mouse movement across DOM node boundaries, and there are many more mouse movements than DOM node boundary crossings, so this technique would be comparatively inefficient without some corrective action. We need to debounce the function tracking the mouse movements by limiting the rate of its successive invocations.

Here's a simple debounce function which takes an input function and adds a rate limit.

  const milliseconds = 1000
  const debounce = (fn) => {
    var timeout
    return function(...args) {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        timeout = null
        fn.apply(this, args)
      }, milliseconds)
    }
  }

Standard Mouseovers

With conventional user interactions the tooltip function would usually just fire on mouseover.

  const mouseoverStandard = selection => {
    selection
      .selectAll('circle')
      .on('mouseenter', function() {
        tooltip(selection, d3.select(this))
      })
  }

Snapping Mouseovers

The key to this technique is to first determine the hit point closest to the user interaction, and then run the tooltip function there.

  const mouseoverSnap = wrapper => {
    wrapper
      .on('mousemove', function() {
        // capture position relative to data rectangle
        const rect = d3.select(this).select('rect').node()
        const mouse = d3.mouse(rect)
        // convert interaction point into data space
        const datum = {
          x: x.invert(mouse[0]),
          y: y.invert(mouse[1])
        }
        // find closest data point
        const distances = data
          .map(item => {
            // essentially just the pythagorean theorem
            const dx = Math.abs(datum.x - item.x)
            const dy = Math.abs(datum.y - item.y)
            const distance = Math.sqrt(
              Math.pow(dy, 2) +
              Math.pow(dx, 2)
            )
            return distance
          })
        // closest datum
        const index = distances.indexOf(d3.min(distances))
        const closest = data[index]
        // corresponding node
        const match = wrapper
          .selectAll('circle')
          .filter(d => {
            return d.x === closest.x && d.y === closest.y
          })
        // run tooltip function
        tooltip(wrapper, match)
      })
  }

Demonstration

Let's actually put all the above pieces together and render so it's easier to actually see the difference between them.

Data

Generate a set of random points to plot in the chart.

  const pad = 0.05 * max
  const random = d3.randomUniform(min + pad, max - pad)
  const item = () => ({
    x: Math.round(random()),
    y: Math.round(random())
  })
  data = Array.from({length: 100}).map(item)

Chart

Here's a function which creates a scatter plot of the data points generated above. I won't bother annotating this part of the code because the chart over which you deploy this technique isn't really the point here.

  const chart = selection => {
    const dimensions = ['x', 'y']
    const translate = `translate(${margin.left}, ${margin.top})`
    const wrapper = selection
      .append('g')
      .classed('wrapper', true)
      .attr('transform', translate)
    wrapper
      .append('rect')
      .attr('x', margin.left)
      .attr('y', margin.top)
      .attr('height', height - margin.top)
      .attr('width', width - margin.left)
    const axes = wrapper
      .append('g')
      .classed('axes', true)
    dimensions.forEach(dimension => {
      const dir = dimension === 'x'
      const position = dir ? 'Bottom' : 'Left'
      const axis = d3[`axis${position}`]()
        .scale(dimension === 'x' ? x : y)
      axes.append('g')
        .classed(`axis-${dimension}`, true)
        .attr('transform', `
          translate(
            ${dir ? 0 : margin.left},
            ${dir ? height : 0}
          )
        `)
        .call(axis)
    })
    const points = wrapper
      .append('g')
      .classed('points', true)
    wrapper.append('text').classed('tooltip', true)
    const point = points
      .selectAll('circle')
      .data(data)
      .enter()
      .append('circle')
    point
      .attr('r', radius)
      .attr('cx', d => x(d.x))
      .attr('cy', d => y(d.y))
  }

Renderings

Run the chart function twice to generate two copies of the same chart.

  const example = d3.select('main')
    .selectAll('div.example')
    .data(['standard', 'snapping'])
    .enter()
    .append('div')
    .classed('example', true)
  example
    .append('h2')
    .text(d => `${d} mouseovers`)
  example
    .append('svg')
    .attr('height', height + margin.bottom + margin.top)
    .attr('width', width + margin.left + margin.right)
    .attr('class', d => `chart-${d}`)
    .call(chart)

Event Listeners

With our debounce helper at the ready, we can now attach the mouseover functions to our two charts.

  d3.select('div.example:nth-child(1)')
    .call(debounce(mouseoverStandard))
  d3.select('div.example:nth-child(2)')
    .call(debounce(mouseoverSnap))

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>
main {
text-align: center;
position: relative;
cursor: pointer;
}
div.example {
display: inline-block;
}
h2 {
text-align: center;
}
svg {
margin: 10px;
}
circle {
fill: hsl(200, 100%, 80%);
}
rect {
opacity: 0.001;
}
text.tooltip {
font-size: xx-small;
font-weight: bold;
text-anchor: middle;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment