Search code examples
svgd3.jscanvasgeo

d3.js: convert SVG geo-path to canvas


I'm new to D3 and just wrote a small code to display point transitions on a map: Static Image
I started with a small sample of ~100 points, which works fine.

But eventually, I want to display about 1000 points as in this animation here: https://www.zeit.de/feature/pendeln-stau-arbeit-verkehr-wohnort-arbeitsweg-ballungsraeume

But even 500 points seem to be too much to handle for D3 and the browser - the animations don't repeat anymore.

I read that I should use canvas for better performance, but after reading some tutorials I still cannot properly rewrite my SVG code to canvas code. Any help on how to correctly use canvas here appreciated.

The code using SVG:

Promise.all([
        d3.json('http://opendata.ffe.de:3000/rpc/map_region_type?idregiontype=2&generalized=1'),
        d3.csv('../Daten/d3_input_sample_klein.csv') 
    ]).then(([bl, centroids]) => {
        var aProjection = d3.geoMercator()
            .fitSize([800, 600], bl);
        
        var geoPath = d3.geoPath()
            .projection(aProjection);

        var svg = d3.select('body')
            .append('svg')
            .attr('width', 1000)
            .attr('height', 1000);

        // draw basemap
        svg.selectAll('path')
            .data(bl.features)
            .enter()
            .append('path')
            .attr('d', geoPath)
            .attr('class', 'bl');

        // get max value for scaleLinear
        var max = d3.max(centroids, function (d) {
            return parseInt(d.value);
        });

        var radiusScale = d3.scaleLinear()
            .domain([0,max])
            .range([1, 10]);

        // create circles with radius based on "value"
        function circleTransition() {
            var circles = svg.selectAll('circle')
                .data(centroids)
                .enter()
                .append('circle')
                .style('fill', 'white')
                .attr('r', function (d) {
                    return radiusScale(d.value);
                });
            repeat();

            // transition circles from "start" to "target" and repeat
            function repeat() {
                circles
                    .attr('cx', (d) => aProjection([d.x_start, d.y_start])[0])
                    .attr('cy', (d) => aProjection([d.x_start, d.y_start])[1])
                    .transition()
                    .duration(4000)
                    .attr('cx', (d) => aProjection([d.x_target, d.y_target])[0])
                    .attr('cy', (d) => aProjection([d.x_target, d.y_target])[1])
                    .on('end', repeat);
            };
        };
        circleTransition();

The loaded CSV file contains lat/lon coordinates like this:

x_start y_start x_target y_target value
9.11712 54.28097 8.77778 54.71323 122
9.79227 53.64759 9.60330 53.86844 87617
9.70219 53.58864 8.80382 54.80330 2740

Is there an easy way to transform this code to using canvas or improve performance in another way?

Thanks! Michael


Solution

  • I'm not certain you need to convert to canvas. 20 000 transitioning svg nodes should be slow, but 500 should be manageable. For comparision, here's 20 000 canvas nodes transitioning, and for comparision of different strategies this might be interesting.

    I'll provide an optimization for your SVG code, but also how you might do it with canvas.

    Keeping SVG

    You aren't using d3.transition effeciently - which may be the cause of performance concerns.

    The inefficiency in d3.transition is in how you use transition.on("end", this method calls a function at the end of the transition of every element being transitioned. As you have 500 elements being transitioned, you call this function 500 times on all 500 elements, in effect you are attempting to start transitions on individual elements 250,000 times each cycle. Each initialization of a transition calls the projection function 4 times, so you are projecting points a million times per cycle when you only need to do so 2000 times (though this could be reduced to 1000).

    Instead we can create a transition function that transitions a single feature and at the end retriggers that transition function, eg:

             svg.selectAll("circle")
               .each(repeat); // call repeat function on individual circles
    
             function repeat() {
                   d3.select(this)
                    .attr('cx', (d) => aProjection([d.x_start, d.y_start])[0])
                    .attr('cy', (d) => aProjection([d.x_start, d.y_start])[1])
                    .transition()
                    .duration(4000)
                    .attr('cx', (d) => aProjection([d.x_target, d.y_target])[0])
                    .attr('cy', (d) => aProjection([d.x_target, d.y_target])[1])
                    .on('end', repeat);
            };
    

    Here's a slightly modified example:

    var data = [{x:100,y:100},{x:200,y:100}];
    var svg = d3.select("svg");
    
    var circles = svg.selectAll('circle')
      .data(data)
      .enter()
      .append('circle')
      .style('fill', 'black')
      .attr('r', 10)
      .attr("cx", d=>d.x)
      .attr("cy", d=>d.y)
           
               
    svg.selectAll("circle")
      .each(repeat);
    
    function repeat() {
       d3.select(this)
         .style("fill","orange")
         .transition()
         .duration(4000)
         .style("fill","steelblue")
         .on('end', repeat);
    };
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
    <svg></svg>

    This also allows each transition to be subject to its own duration/delay without waiting for all the other transitions to finish before starting again.

    Canvas

    Canvas will require a fairly complete reworking of your code and requires a different approach to d3. Functionality like mouse interaction require very different approaches. I'm assuming at least a basic familiarity with canvas below as the question is about how to transition using canvas, not how to use canvas.

    For underlying geographic features we can draw these fairly easily with canvas, which comprises of a few steps:

     // set up canvas and context
     var canvas = d3.select("canvas")
     var context = canvas.node().getContext("2d");
     
     // create the path
     var path = d3.geoPath().projection(projection).context(context);
    
     // draw a feature:
     context.beginPath()
     path(geojsonFeature)
     context.stroke();
    

    To do the transition there are a few options, I'll use d3.transition here, we can call it on the canvas itself. But we'll need to use a tween function to access where we are in the transition and how to draw the circles each frame of the transition.

    transition.tween is used to set some property of an html/svg element over the course of a transition. The pixels in the canvas aren't html/svg elements and have no attributes/styles we can set. But we can use transition.tween on some unused attribute to gain access to its functionality:

     canvas.transition()
      .tween("whatever",tweeningFunction);
    

    The tweeningFunction returns an interpolator that takes a single parameter (t) which represents the progress of the transition. t ranges from 0 to 1. The returned interpolator gets called repeatedly throughtout the transition, so we can use it to position the canvas circles:

     function tween() {
       // return interpolator:
       return function(t) {
    
           // clear the canvas once per frame:
           context.clearRect(0, 0, width, height);
     
           // draw all points each frame:
           data.forEach(function(d) {
               // Start drawing a path:
               context.beginPath();
               // interpolate where point is based on t 
               var p = d3.interpolateObject(d.p0,d.p1)(t);  
               // draw that point:
               context.arc(p.x,p.y,10,0,2*Math.PI);
               context.fill();
           }) 
           return ""; // return a value to set the "whatever" attribute
     }
    

    As a working example:

    var data = [
    { p0: {x:100,y:100}, p1: {x:120,y:200} },
    { p0: {x:150,y:100}, p1: {x:400,y:150} },
    { p0: {x:200,y:250}, p1: {x:120,y:20} },
    ];
    
    var canvas = d3.select("canvas")
    var context = canvas.node().getContext("2d");
    
    var width = 500;
    var height = 300;
    
    canvas.call(repeat);
    
    function repeat() {
      d3.select(this).transition()
        .tween("nothing",tween)
        .duration(1000)
        .on("end",repeat);
    }
    
    function tween() {
       
       var interpolote = d3.interpoloate;
       return function(t) {
           // clear the canvas:
           context.clearRect(0, 0, width, height);
     
           // draw all points each frame:
           data.forEach(function(d) {
               // Start drawing a path:
               context.beginPath();
               // interpolate where point is based on t 
               var p = d3.interpolateObject(d.p0,d.p1)(t);  
               // draw that point:
               context.arc(p.x,p.y,10,0,2*Math.PI);
               context.fill();
           })  
           return "";
        }
        
    }
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
    <canvas width="500" height="300"></canvas>

    I don't know the rest of your code, so I can't say how to convert it all to canvas, though that might be a few separate questions. I can break apart the tweening function further if you want, but it does assume a baseline understanding of canvas