Skip to content

Instantly share code, notes, and snippets.

@starcalibre
Last active July 21, 2019 07:32
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save starcalibre/5dc0319ed4f92c4fd9f9 to your computer and use it in GitHub Desktop.
Save starcalibre/5dc0319ed4f92c4fd9f9 to your computer and use it in GitHub Desktop.
Fast Interactive Canvas Scatterplot
.idea/
*.iml

A recent project I was working on involved creating an interactive scatterplot that ran smooth whe handling thousands of points. This is well and truly beyond what SVG and the DOM is capable of, so using the HTML5 Canvas seemed like a good idea. Canvas has its downsides however -- no styling points with CSS, no event binding and cool features of D3 like the axes drawing aren't implemented in canvas.

This example shows how the canvas and SVG can work together to get the best of both worlds. Points are drawn using the canvas, while the axis are drawing using SVG. The canvas and SVG are layered on top of eachother strategically to get this effect.

Fast hit detection is enabled via the D3 quadtree data structure, and the plot runs smoothly during zoom events by drawing a subset of the points rather than the full dataset.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
text {
font: 10px sans-serif;
}
.plot {
position: absolute;
}
#plot-canvas {
z-index: 2;
}
#axis-svg {
z-index: 1;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.tick line{
opacity: 0.2;
}
</style>
</head>
<body>
<svg id="axis-svg" class="plot"></svg>
<canvas id="plot-canvas" class="plot"></canvas>
</body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.12/d3.min.js" charset="utf-8"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.0.0/lodash.min.js"></script>
<script>
// constants
var numberPoints = 5000;
var subsetSize = 150;
var pointRadius = 6;
var zoomEndDelay = 250;
// timeout function
var zoomEndTimeout;
// save the index of the currently selected point
var selectedPoint;
// define all size variables
var fullWidth = 500;
var fullHeight = 500;
var margin = {top: 10, right: 10, bottom: 30, left: 30};
var width = fullWidth - margin.left - margin.right;
var height = fullHeight - margin.top - margin.bottom;
// generate random dataset
var randomX = d3.random.normal(0, 30);
var randomY = d3.random.normal(0, 30);
var data = d3.range(numberPoints).map(function(d, i) {
return {
x: randomX(),
y: randomY(),
i: i, // save the index of the point as a property, this is useful
selected: false
};
});
// create a quadtree for fast hit detection
var quadTree = d3.geom.quadtree(data);
// selected 250 random numbers -- this is the subset of points
// drawn during 'zoom' events
var randomIndex = _.sampleSize(_.range(numberPoints), subsetSize);
// the canvas is shifted by 1px to prevent any artefacts
// when the svg axis and the canvas overlap
var canvas = d3.select("#plot-canvas")
.attr("width", width - 1)
.attr("height", height - 1)
.style("transform", "translate(" + (margin.left + 1) +
"px" + "," + (margin.top + 1) + "px" + ")");
var svg = d3.select("#axis-svg")
.attr("width", fullWidth)
.attr("height", fullHeight)
.append("g")
.attr("transform", "translate(" + margin.left + "," +
margin.top + ")");
// ranges, scales, axis, objects
var xRange = d3.extent(data, function(d) { return d.x });
var yRange = d3.extent(data, function(d) { return d.y });
var xScale = d3.scale.linear()
.domain([xRange[0] - 5, xRange[1] + 5])
.range([0, width]);
var yScale = d3.scale.linear()
.domain([yRange[0] - 5, yRange[1] + 5])
.range([height, 0]);
var xAxis = d3.svg.axis()
.scale(xScale)
.innerTickSize(-height)
.outerTickSize(0)
.tickPadding(10)
.orient('bottom');
var yAxis = d3.svg.axis()
.scale(yScale)
.innerTickSize(-width)
.outerTickSize(0)
.orient('left');
// create zoom behaviour
var zoomBehaviour = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([1, 10])
.on("zoom", onZoom)
.on("zoomend", onZoomEnd);
// append x-axis, y-axis
var xAxisSvg = svg.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0,' + height + ')')
.call(xAxis);
var yAxisSvg = svg.append('g')
.attr('class', 'y axis')
.call(yAxis);
// on onclick handler
canvas.on("click", onClick);
// add zoom behaviour
canvas.call(zoomBehaviour);
// get the canvas drawing context
var context = canvas.node().getContext('2d');
draw();
function onClick() {
var mouse = d3.mouse(this);
// map the clicked point to the data space
var xClicked = xScale.invert(mouse[0]);
var yClicked = yScale.invert(mouse[1]);
// find the closest point in the dataset to the clicked point
var closest = quadTree.find([xClicked, yClicked]);
// map the co-ordinates of the closest point to the canvas space
var dX = xScale(closest.x);
var dY = yScale(closest.y);
// register the click if the clicked point is in the radius of the point
var distance = euclideanDistance(mouse[0], mouse[1], dX, dY);
if(distance < pointRadius) {
if(selectedPoint) {
data[selectedPoint].selected = false;
}
closest.selected = true;
selectedPoint = closest.i;
// redraw the points
draw();
}
}
function onZoom() {
clearTimeout(zoomEndTimeout);
draw(randomIndex);
xAxisSvg.call(xAxis);
yAxisSvg.call(yAxis);
}
function onZoomEnd() {
// when zooming is stopped, create a delay before
// redrawing the full plot
zoomEndTimeout = setTimeout(function() {
draw();
}, zoomEndDelay);
}
// the draw function draws the full dataset if no index
// parameter supplied, otherwise it draws a subset according
// to the indices in the index parameter
function draw(index) {
var active;
context.clearRect(0, 0, fullWidth, fullHeight);
context.fillStyle = 'steelblue';
context.strokeWidth = 1;
context.strokeStyle = 'white';
// if an index parameter is supplied, we only want to draw points
// with indices in that array
if(index) {
index.forEach(function(i) {
var point = data[i];
if(!point.selected) {
drawPoint(point, pointRadius);
}
else {
active = point;
}
});
}
// draw the full dataset otherwise
else {
data.forEach(function(point) {
if(!point.selected) {
drawPoint(point, pointRadius);
}
else {
active = point;
}
});
}
// ensure that the actively selected point is drawn last
// so it appears at the top of the draw order
if(active) {
context.fillStyle = 'red';
drawPoint(active, pointRadius);
context.fillStyle = 'steelblue';
}
}
function drawPoint(point, r) {
var cx = xScale(point.x);
var cy = yScale(point.y);
// NOTE; each point needs to be drawn as its own path
// as every point needs its own stroke. you can get an insane
// speed up if the path is closed after all the points have been drawn
// and don't mind points not having a stroke
context.beginPath();
context.arc(cx, cy, r, 0, 2 * Math.PI);
context.closePath();
context.fill();
context.stroke();
}
function euclideanDistance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}
</script>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment