Skip to content

Instantly share code, notes, and snippets.

@robinfhu
Last active September 10, 2023 10:53
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save robinfhu/c159eca5249af366c307 to your computer and use it in GitHub Desktop.
Save robinfhu/c159eca5249af366c307 to your computer and use it in GitHub Desktop.

Chart tooltips using quadtrees

Adding tooltip interactivity to charts is one of the most desired features, but also one of the tricker ones to implement. One common solution is to attach .on('mouseover') event handlers to each circle element. This will work okay, except when points start overlapping. You will also start using up lots of memory if the number of points is very large.

A more advanced technique is to use d3.geom.voronoi. This solves the problem of overlapping points, but you still run into performance issues. That's because the solution involves generating path elements and attaching event handlers to each element. You also need to recalculate the voronoi pattern if the chart resizes.

One solution that can handle overlapping points and be performant, is to use a data structure called quadtree. You can read about quadtrees here

The advantage of quadtrees is that the point location algorithm happens completely in memory. There is no need to generate elements on the DOM and attach event handlers to each. Even if you resize the browser window, the quadtree structure stays the same and will work. I've only created one event handler in my example: a mousemove attached to the svg element. All other SVG elements have pointer-events:none set, so as to not interfere with the mousemove event.

The algorithm to highlight a point work as follows:

1.  Create a quadtree from all the data points.
2.  Locate the current x,y coordinates of the mouse in the SVG container.
3.  Convert the x,y coordinates to the plotted coordinates.  So [480px, 200px] would correspond to [0,0].
4.  Use `quadtree.find()` to locate the point nearest to your mouse.
5.  Highlight the found point, only if it is close enough to your mouse.

You can see the effect in the example above. It works well when points are relatively isolated, but shows some flaws when points are very close together. If a point happens to lie right on the edge of a square in the quadtree, then the mouse pretty much needs to be exactly over the point to work. With some extra effort, there are likely ways to increase the accuracy of this technique.

See my website for another implementation of this technique.

<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>Quadtree</title>
<style>
body {
font-family: Helvetica;
}
circle.point {
fill: #8A84CE;
fill-opacity: 0.7;
stroke: #333;
pointer-events: none;
}
circle.marker {
fill: orange;
pointer-events: none;
}
line.axes {
stroke: #333;
pointer-events: none;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"></script>
</head>
<body>
<div id='point'>[]</div>
<script>
var width = 960,
height = 400;
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
//Set of data points between -1 and 1
var data = d3.range(200).map(function() {
return [Math.random() * 2 - 1, Math.random() * 2 - 1];
});
//Setup scales
var xScale = d3.scale.linear().domain([-1,1]).range([0, width]);
var yScale = d3.scale.linear().domain([-1,1]).range([height, 0]);
//Add axes lines
svg.append('line')
.classed('axes', true)
.attr('x1',0)
.attr('x2', width)
.attr('y1', yScale(0))
.attr('y2', yScale(0));
svg.append('line')
.classed('axes', true)
.attr('x1',xScale(0))
.attr('x2', xScale(0))
.attr('y1', 0)
.attr('y2', height);
svg.append('circle')
.classed('marker', true)
.attr('r', 0);
var points = svg.selectAll('circle.point').data(data);
points.enter().append('circle').classed('point', true).attr('r',4);
points
.attr('cx', function(d) { return xScale(d[0]); })
.attr('cy', function(d) { return yScale(d[1]); });
var quadtree = d3.geom.quadtree()(data);
svg.on('mousemove', function() {
var mouse = d3.mouse(this);
var x = xScale.invert(mouse[0]);
var y = yScale.invert(mouse[1]);
var point = quadtree.find([x,y]);
if (point !== null) {
var xPos = xScale(point[0]);
var yPos = yScale(point[1]);
var dist = Math.sqrt((xPos - mouse[0])*(xPos - mouse[0]) + (yPos - mouse[1])*(yPos - mouse[1]));
if (dist < 15) {
var format = d3.format('.2f');
d3.select('#point').text("[" + format(point[0]) + " , " + format(point[1]) + "]");
svg.select('circle.marker')
.attr('cx', xPos)
.attr('cy', yPos)
.transition()
.attr('r', 10);
}
else {
d3.select('#point').text("[]");
svg.select('circle.marker').attr('r', 0);
}
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment