3

I am using Leaflet with Leaflet-D3's hexbin. I wanted to export the map with the hexbin layer to an image. For exporting maps to an image, I've been using Leaflet-image so far. However, since the hexbin overlay is an SVG layer, when I export the map+hexbin using that same library, only the map appears on the image.

What can I do to export that hexbin layer to the same image produced by Leaflet-image? I've seen tools to export SVG to image, for example, but this wouldn't fully work in my case, since it would only address the hexbin and not the map.

flapas
  • 583
  • 1
  • 11
  • 25

1 Answers1

3

This is actually pretty cool. Using the code described here, you can actually combine the generated leaflet-image with an image created from the SVG.

Since code speaks louder than words, here's a sample I put together:

<!DOCTYPE html>
<html>

<head>
  <script type="text/javascript" src="https://d3js.org/d3.v3.min.js"></script>
  <script type="text/javascript" src="https://rawgit.com/d3/d3-plugins/master/hexbin/hexbin.js"></script>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js"></script>
  <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css">
  <script type="text/javascript" src="https://rawgit.com/Asymmetrik/leaflet-d3/master/dist/leaflet-d3.js"></script>
  <script src="https://rawgit.com/mapbox/leaflet-image/gh-pages/leaflet-image.js"></script>
</head>

<body>

  <div id="map" style="width: 600px; height: 400px; border: 1px solid #ccc"></div>
  <button onclick="generateImage()">Create Image</button>
  <div id="images"></div>

  <script>
    var center = [39.4, -78];

    var osmUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
      osmAttrib = '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      osm = L.tileLayer(osmUrl, {
        maxZoom: 18,
        attribution: osmAttrib
      });

    map = new L.Map('map', {
      layers: [osm],
      center: new L.LatLng(center[0], center[1]),
      zoom: 7
    });

    var options = {
      radius: 12,
      opacity: 0.5,
      duration: 500,
      lng: function(d) {
        return d[0];
      },
      lat: function(d) {
        return d[1];
      },
      value: function(d) {
        return d.length;
      },
      valueFloor: 0,
      valueCeil: undefined
    };

    var hexLayer = L.hexbinLayer(options).addTo(map)
    hexLayer.colorScale().range(['white', 'blue']);

    var latFn = d3.random.normal(center[0], 1);
    var longFn = d3.random.normal(center[1], 1);
    
    var generateData = function() {
      var data = [];
      for (i = 0; i < 1000; i++) {
        data.push([longFn(), latFn()]);
      }
      hexLayer.data(data);
      
      d3.selectAll('.hexbin-hexagon')
        .style({
            "stroke": '#000',
            "stroke-width": '1px'
        });
    };
    
    generateData();
    
    var getOverlay = function(){
        // Select the first svg element
        var svg = d3.select('.leaflet-overlay-pane>svg'),
            img = new Image(),
            serializer = new XMLSerializer(),
            svgStr = serializer.serializeToString(svg.node());
            
        img.src = 'data:image/svg+xml;base64,'+window.btoa(svgStr);
        
        return {
          img: img,
          w: +svg.attr('width'),
          h: +svg.attr('height')
        }
    };

    var generateImage = function() {
      leafletImage(map, function(err, canvas) {
        
        var d3O = getOverlay();
        canvas.getContext("2d").drawImage(d3O.img,0,0,d3O.w,d3O.h);
        
        // now you have canvas
        // example thing to do with that canvas:
        var img = document.createElement('img');
        var dimensions = map.getSize();
        img.width = dimensions.x;
        img.height = dimensions.y;
        img.src = canvas.toDataURL();
        document.getElementById('images').innerHTML = '';
        document.getElementById('images').appendChild(img);
      });
    };
  </script>
</body>

</html>

My initial code didn't take into account zooming and panning. Here's a re-write. It's a bit crazy so and I only tested in chrome. To be honest, at this point, I would render this server-side with phantomJS, capture that to a JPEG and return it to the browser.

<!DOCTYPE html>
<html>

<head>
  <script type="text/javascript" src="https://d3js.org/d3.v3.min.js"></script>
  <script type="text/javascript" src="https://rawgit.com/d3/d3-plugins/master/hexbin/hexbin.js"></script>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js"></script>
  <link rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css">
  <script type="text/javascript" src="https://rawgit.com/Asymmetrik/leaflet-d3/master/dist/leaflet-d3.js"></script>
  <script src="https://rawgit.com/mapbox/leaflet-image/gh-pages/leaflet-image.js"></script>
</head>

<body>

  <div id="map" style="width: 600px; height: 400px; border: 1px solid #ccc"></div>
  <button onclick="generateImage()">Create Image</button>
  <div id="images"></div>

  <script>
    var center = [39.4, -78],
        width = 600,
        height = 400;

    var osmUrl = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
      osmAttrib = '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      osm = L.tileLayer(osmUrl, {
        maxZoom: 18,
        attribution: osmAttrib
      });

    map = new L.Map('map', {
      layers: [osm],
      center: new L.LatLng(center[0], center[1]),
      zoom: 7
    });

    var options = {
      radius: 12,
      opacity: 0.5,
      duration: 500,
      lng: function(d) {
        return d[0];
      },
      lat: function(d) {
        return d[1];
      },
      value: function(d) {
        return d.length;
      },
      valueFloor: 0,
      valueCeil: undefined
    };

    var hexLayer = L.hexbinLayer(options).addTo(map)
    hexLayer.colorScale().range(['white', 'blue']);

    var latFn = d3.random.normal(center[0], 1);
    var longFn = d3.random.normal(center[1], 1);
    
    var generateData = function() {
      var data = [];
      for (i = 0; i < 1000; i++) {
        data.push([longFn(), latFn()]);
      }
      hexLayer.data(data);
    };
    
    generateData();

    var getOverlay = function(){
        // Select the first svg element
        var svg = d3.select('.leaflet-overlay-pane>svg'),
            img = new Image(),
            serializer = new XMLSerializer();
           
        svg.select("g").attr("transform", null);
        svg.style("margin-top", null);
        svg.style("margin-left", null);
        svg.attr("height", null);
        svg.attr("width", null);
        var svgStr = serializer.serializeToString(svg.node());

        img.src = 'data:image/svg+xml;base64,'+window.btoa(svgStr);
        
        return img;
    };

    var generateImage = function() {
      leafletImage(map, function(err, canvas) {
        
        var t = d3.select('.leaflet-map-pane').style('transform').split(", "),
           img = getOverlay(),
           x = parseInt(t[4]),
           y = parseInt(t[5]);

        canvas.getContext("2d").drawImage(img,
          x,
          y,
          width,
          height
        );
        
        // now you have canvas
        // example thing to do with that canvas:
        var img = document.createElement('img');
        var dimensions = map.getSize();
        img.width = dimensions.x;
        img.height = dimensions.y;
        img.src = canvas.toDataURL();
        document.getElementById('images').innerHTML = '';
        document.getElementById('images').appendChild(img);
      });
    };
  </script>
</body>

</html>
Community
  • 1
  • 1
Mark
  • 106,305
  • 20
  • 172
  • 230
  • Love your idea! However, it's not fully working... In addition to not being very responsive (it looks like it only captures on only one in two click), the final image doesn't seem to be the same as the map+hexbin captured, as you can [see in this screenshot (focus on Baltimore)](https://snag.gy/Q7hLOE.jpg). There was also another problem, as you can [see in this other screenshot](https://snag.gy/fkyFMD.jpg). Is this related to your particular example? – flapas Aug 12 '16 at 10:03
  • 1
    Ok, I know what's the problem with this solution. The whole SVG layer is being captured, and put over the canvas. This works fine, **if you don't move the map**. If you pane the map to the sides, the whole SVG will be "pasted" on top of the "uncentered" map, resulting in some images such as the ones I showed you. So we need to pan the SVG layer as much as the map. How? no ideia... – flapas Aug 14 '16 at 18:14
  • @pavlag, it should be solvable. I have been travelling for last few days. Will take a look this week when I return. – Mark Aug 14 '16 at 18:17
  • @pavlag, one more thing. The non-responsiveness of the code it nothing I'm doing. The `leaflet-image.js` plugin is downloading all the image tiles in the background and that's the slowness you see. – Mark Aug 15 '16 at 19:58
  • yeah it's working in chrome! Tested in safari and didn't work, though.. any idea of possible incompatibilities in the code? PS: I would also love to just render it server side, since I would have much more options for this, but I really can't have one dedicated server – flapas Aug 22 '16 at 09:52
  • [here's an example](https://snag.gy/7ug8je.jpg) of what's happening in safari. even the original SVG get's all weird – flapas Aug 22 '16 at 10:03
  • turns out it's not even working 100% on chrome @Mark . In my project, the SVG appears to be sliced on the final image, as if I ordered for it to be limited to a certain width or height. On a plunkr with your code, by clicking "Create Image", the original SVG layer on the map is sometimes affected, and partially disappears. – flapas Aug 24 '16 at 15:57
  • @pavlag, the only other idea I have to do this client side is to use [canvg](https://github.com/gabelerner/canvg) to generate the canvas from the SVG, but I'm sure you'll hit problems going that direction too. If I was you, I'd pursue doing this server side with [phantomjs](http://phantomjs.org/) or batik. – Mark Aug 24 '16 at 16:03
  • 1
    got it working making some minor modifications: 1 - svg-image should be drawn with its width and height. 2- minor error in `y = parseInt(t[5]);` which should be `y = parseInt(t[5].split(")")[0]`. See full script in [saving-svg-leaflet-gist](https://gist.github.com/miguelvb/4d988a9521ebcf19f19bdb67e82535a5) – Miguel Vazq Mar 13 '17 at 20:38