block by ejb e2da5a23e9a09d494bd532803d8db61c

Fast Interactive Canvas Scatterplot

Full Screen

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.

index.html

<!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>