Printing in Leaflet

It’s been a while since I posted anything to this blog, but that doesn’t mean I haven’t been busy. I’ve been having all kinds of adventures working with Leaflet and making it do interesting things it probably wasn’t intended for. I’ll try to catch up with writing about some of these over the next few posts.

My most recent triumph involves printing a Leaflet map. Now, I know what you’re going to say: Why would you print a Leaflet map? Aside from the snarky answer why not?, the project I’m working on requires a map that is both interactive and can go where there are no mobile devices or internet access, and that requires printing.

Before I get into the technical stuff, I want to briefly expound on the broader implications of what I’m about to cover. We in the cartography world are generally split down the middle when it comes to media: either you make static maps for print or you make interactive web maps. Generally, the only crossovers are static maps that get plopped online Web 1.0-style, as images or PDFs. I think it’s high time we start thinking about transcending these media silos with our maps. Like, can you print an SVG graphic generated by D3? Sure you can, but can you control the scale at which it prints and make it look good? Similarly, how do we make zoomable, panable slippy maps, with all the advantages those entail for web users, and make them printable as a resource for those who need to draw on top of them or pick them up and take them where reliable internet access doesn’t exist?

The specific map I’ve been working on for a little over a year now is wikimap of data collected in eastern Senegal, which will enable trusted users with local knowledge to edit the data and contribute new data. One of the requirements of the application is that it be printable as posters to take to village meetings. The map utilizes an underlying satellite imagery tileset, a custom tileset with the polygon and line data (hosted with Tilestrata on Amazon AWS, which is a whole other blog post waiting to happen), and the point data as overlays added with your typical L.geoJson calls.

Screenshot1.png
Couloirs Transhumance,
a map of herding routes in eastern Senegal

One challenge is that the map covers such a huge geographic area and includes so much data that a printed version of the entire thing would be unintelligible unless printed on a very large poster. Thus, users need to be able to choose both the scale of the map they print and the paper size, and they need to be able to preview what they’re going to print. So I built a print preview window.

Screenshot2_crop
Teh Printerface!

First of all, notice there are no satellite image tiles on the map. Satellite images are basically photos (but from spaaaaaaaace). Have you ever tried printing a photo at 72 dpi? It looks. like. crap. Likewise, raster tilesets look like crap when printed. Ditch ’em.

 

But I still wanted to take advantage of Leaflet’s smooth interaction capabilities to allow the user to control the map view that they’re going to print. Thus, I created a new Leaflet map with no base layer and L.geoJson overlays for all of the mapped data, including the two-dimensional features that are burned into my custom tileset on the main map. When there are thousands of SVG paths and raster icons on the map, it slows things down a bit. So I had to kill scroll wheel zoom and get the zoom buttons off the map anyway, since they’re not going to be present on the final printout. Hence the scale bar, which represents the Leaflet zoom levels as tics on a line to give users an idea of how close they are to the minimum or maximum zoom.

You’ll notice that under the scale bar is an actual, honest-to-godess ratio scale, which applies to the printed map. Okay, so there’s like, A LOT of math behind this, because the map scale varies based on latitude, zoom level of the map, page size, and the size and shape of the preview window. Here’s the code:

function adjustScale(){
    //change symbol sizes and ratio scale according to paper size
    var prevWidth = $("#printPreview").width();
    var prevHeight = $("#printPreview").height();
    var longside = getLongside();
         
    //find the mm per pixel ratio
    var mmppPaper = prevWidth > prevHeight ? longside / prevWidth : longside / prevHeight;
    var mapZoom = printPreviewMap.getZoom();
    var scaleText = $("#printBox .leaflet-control-scale-line").html().split(" ");
    var multiplier = scaleText[1] == "km" ? 1000000 : 1000;
    var scalemm = Number(scaleText[0]) * multiplier;
    var scalepx = Number($("#printBox .leaflet-control-scale-line").width());
    var mmppMap = scalemm / scalepx;
    var denominator = Math.round(mmppMap / mmppPaper);
    $("#ratioScale span").text(denominator);
    $("#previewLoading").hide();
    return [mmppMap, mmppPaper];
};

function getLongside(){
    //get longside in mm minus print margins
    var size = $("#paperSize select option:selected").val();
    var series = size[0];
    var pScale = Number(size[1]);
    var longside;
    if (series == "A"){ //equations for long side lengths in mm, minus 10mm print margins
        longside = Math.floor(1000/(Math.pow(2,(2*pScale-1)/4)) + 0.2) - 20;
    } else if (series == "B"){
        longside = Math.floor(1000/(Math.pow(2,(pScale-1)/2)) + 0.2) - 20;
    };
    return longside;
};

What the printerface does is give the user access to all of these variables, and change the map scale depending on them. Fortunately, international paper sizes greatly simplify this math by maintaining the same aspect ratio (√2) regardless of size. If the window size changes, the preview map can change size proportionally and still represent the printed page. In order to better represent what the printout will look like, the ratios allow for automatically adjusting the size of the symbols on the map based on the window size or chosen paper size. So if the user changes the paper size to, say, A1 (a typical poster size), the preview map looks like this:

Screenshot3_crop.png

Note that the ratio scale has increased quite a bit. Think about what this will look like when it turns into a 841 mm x 594 mm poster. The bounding box has been preserved, symbols will be the same proportions relative to each other as in the preview, and they will be the same absolute size as the symbols printed on any other page size (8 mm wide for the icons). Also note the new labels for village features. These are scripted to show up whenever the scale is greater than 1:250000. More on these in a minute.

The last tricky step to printing is how to actually resize everything so everything on the map prints at the right size with the correct bounding box. Folks, I’m here to tell you, figuring this out was no walk in the park. I may have prematurely lost some hair over it. In the end, the solution was as simple yet un-straightforward as the cheat that lets you beat Myst within the first five minutes (for you whipper-snappers, that’s a shameless 90’s computer game reference). Here’s the code in case you want to pick through it; if you just want the punch line, skip on down.

$("#printButton").click(function(){    

    //transform map pane
    var mapTransform = $("#printPreview .leaflet-map-pane").css("transform"); //get the current transform matrix
    var mmpp = adjustScale(); //get mm per css-pixel
    var multiplier = mmpp[1] * 3.7795; //multiply paper mm per css-pixel by css-pixels per mm to get zoom ratio
    var mapTransform2 = mapTransform + " scale("+ multiplier +")"; //add the scale transform
    $("#printPreview .leaflet-map-pane").css("transform", mapTransform2); //set new transformation

    //set new transform origin to capture panning
    var tfMatrix = mapTransform.split("(")[1].split(")")[0].split(", ");
    var toX = 0 - tfMatrix[4],
        toY = 0 - tfMatrix[5];
    $("#printPreview .leaflet-map-pane").css("transform-origin", toX + "px " + toY + "px");

    //determine which is long side of paper
    var sdim, ldim;
    if ($("#paperOrientation option[value=portrait]").prop("selected")){
        sdim = "width";
        ldim = "height";
    } else {
        sdim = "height";
        ldim = "width";
    };

    //store prior dimensions for reset
    var previewWidth = $("#printPreview").css("width"),
        previewHeight = $("#printPreview").css("height")

    //set the page dimensions for print
    var paperLongside = getLongside(); //paper length in mm minus 20mm total print margins minus border
    $("#printPreview").css(ldim, paperLongside + "mm");
    $("#printPreview").css(sdim, paperLongside/Math.sqrt(2) + "mm");
    $("#container").css("height", $("#printPreview").css("height"));
    
    //adjust the scale bar
    var scaleWidth = parseFloat($("#printBox .leaflet-control-scale-line").css('width').split('px')[0]);
    $("#printBox .leaflet-control-scale-line").css('width', String(scaleWidth * multiplier * 1.1) + "px");
    $("#printBox .leaflet-control-scale").css({
        'margin-bottom': String(5 * multiplier * 1.1) + "px",
        'margin-left': String(5 * multiplier * 1.1) + "px"
    });

    //adjust north arrow
    var arrowWidth = parseFloat($(".northArrow img").css('width').split("px")[0]),
        arrowMargin = parseFloat($(".northArrow").css('margin-top').split("px")[0]);
    $(".northArrow img").css({
        width: String(arrowWidth * multiplier * 1.1) + "px",
        height: String(arrowWidth * multiplier * 1.1) + "px"
    });
    $(".northArrow").css({
        "margin-right": String(arrowMargin * multiplier * 1.1),
        "margin-top": String(arrowMargin * multiplier * 1.1)
    });

    //print
    window.print();

    //reset print preview
    $("#printPreview .leaflet-map-pane").css("transform", mapTransform); //reset to original matrix transform
    $("#printPreview").css({
        width: previewWidth,
        height: previewHeight
    });
    //reset scale bar
    $("#printBox .leaflet-control-scale-line").css('width', scaleWidth+"px");
    $("#printBox .leaflet-control-scale").css({
        'margin-bottom': "",
        'margin-left': ""
    });
    //reset north arrow
    $(".northArrow img").css({
        width: arrowWidth + "px",
        height: arrowWidth + "px"
    });
    $(".northArrow").css({
        "margin-right": arrowMargin,
        "margin-top": arrowMargin
    });
});

Okay, here’s the punchline. The key is using a CSS transform to temporarily scale up the whole leaflet-map-pane div, which holds all of the layers in the print preview map. Leaflet already adjusts the symbol positions using a transform translation, and I need to preserve that transform to reset the map after it’s printed. But to print it, I need to add a scale transform that multiplies the size of everything by the ratio of paper millimeters per screen millimeter (which you get if you cross-multiply paper mm per pixel times pixels per screen mm). Once I figured this out, I had to figure out how to adjust the transform origin so the bounding box didn’t move out from under my paper map. This involved dissecting the transform matrix and turning the last two numbers negative as the x and y coordinates of the transform origin, which moves the whole map back up and to the left, where it should be (I still can’t keep straight why this works even after re-reading the above link, but I’m sure you mathy people can figure it out).

The rest of the above code just messes with the map div and the accessory elements to get them all the right print size, pulls the trigger, then sets everything back right for the screen viewer. Oh, I also have some helpful print CSS styles, which I’m not going to bother explaining:

@media print {
    @page {
        size: auto;
        margin: 10mm;
    }

    body {
        /*border: 5px solid blue;*/
    }

    #container {
        /*border: 4px solid green;*/
        position: absolute;
    }

    #cover, #maparea, #printOptions, #ppmmtest, .closeDialog, .resize, .msg_qq {
        display: none !important;
    }

    #printBox, #printPreview {
        position: absolute;
        bottom: 0;
        left: 0;
        top: 0;
        right: 0;
        /*border: 1px solid red;*/
    }

    #printPreview {
        border: 1px solid black !important;
        background-color: white !important;
    }

    #printPreview span {
        text-shadow: -1px -1px 0 #FFF, 1px -1px 0 #FFF, -1px 1px 0 #FFF, 1px 1px 0 #FFF;
    }

    .leaflet-control-scale-line {
        text-align: center;
    }
}

So yeah, that’s printing from Leaflet in a nutshell. Just to prove I’m not blowing smoke, here’s a scan of the printed version of the second screenshot of the post.

scan.png

Pretty good, huh?

Now, I promised you I would talk about labels. Leaflet and labels are like Cowboy and Octopus. After all, why would you need to put labels on a Leaflet map when they’re baked into your tiles? Well, again we come to this minor issue of printed raster tiles looking like something a dung beetle would enjoy. So I needed to figure out how to plunk labels onto my map. For the area features, which are drawn as SVG overlays, I decided the easiest thing would be to just add the labels as SVG <text> elements, placing them in the center of each feature’s bounding box. Unfortunately, I’ve found that once Leaflet draws its overlays, you can’t just put new elements into the SVG and expect them to render. So I cheated a little and brought in D3 to do the job. Because D3 is magic.

//for SVG polygons, add label to center of polygon
var g = d3.selectAll('#printPreview g'),
    scaleVals = adjustScale(),
    denominator = Math.round(scaleVals[0]/scaleVals[1]),
    areaTextSize = denominator > 500000 ? '0' : String(8/scaleVals[1]),
    labelDivBounds = [];

g.each(function(){
    var gEl = d3.select(this),
        path = gEl.select('path');
    if (path.attr('class').indexOf('|') > -1){
        var labeltext = path.attr('class').split('|')[0],
            bbox = path.node().getBBox(),
            x = bbox.x + bbox.width/2,
            y = bbox.y + bbox.height/2,
            color = path.attr('stroke'),
            textSize = x == 0 && y == 0 ? 0 : areaTextSize,
            text = gEl.append('text')
            .attr({
                x: x,
                y: y,
                'font-size': textSize,
                'text-anchor': 'middle',
                fill: color
            })
            .text(labeltext);
    };
});

One thing I want to point out here is that textSize variable. I was having a bit of trouble with a bunch of labels piling on top of each other at one particular spot on the map, because apparently once a feature is off the map, its bounding box coordinates become the negative of half the corresponding dimension of the SVG. So I just shut those labels off. There’s also a line that sets the areaTextSize to 0 if the scale is less than 1:500,000 to avoid cluttering up the map too much. There’s a similar D3 .each() loop to adjust the labels each time the map is moved or resized that I’m not showing here.

I also wanted to add labels to the village symbols on the map. But these are actually raster icons, not part of the Leaflet overlays SVG. First I played with just a straight JS loop that would use jQuery to grab the icons and plunk absolutely-positioned divs or spans on the map for each icon. This choked the browser. So then my thought was to make Leaflet do the work, creating an L.geoJson layer for all of the labels I wanted, and cooking the labels themselves with a pointToLayer function. The problem here is that the only SVG layers Leaflet creates are paths, and the only non-SVG layers Leaflet creates are icons! No text or any other elements.

So I decided to do something clever. I would trick Leaflet into putting the labels on the map by creating icons with the feature names in an alt attribute and feeding them a bad src URL! Aside from a pesky image 404 error in the Console, this worked great in Firefox. But Chrome annoyingly adds a broken image icon and cuts off the alt text; IE is a little better but still adds an X icon. So finally I decided I just had to add a label class to Leaflet. Fortunately, this wasn’t too hard. I just extended the Icon class with the bear minimum modification to create <span> elements instead of <img> elements:

L.Label = L.Icon.extend({
    _createImg: function (text, el) {
        el = el || document.createElement('span');
        el.innerHTML = text;
        return el;
    }
});

L.label = function (options) {
    return new L.Label(options);
};

Then I just had to instantiate an L.geoJson layer and feed it my label class. Voilá! I magically had village labels on the map! (At scales of over 1:250,000, again to avoid clutter).

Screenshot4_crop.png

I did end up adjusting the CSS just a little with some in-line script and a stylesheet style:

//inline script to adjust label css
$("#printPreview span").css({
    'margin-left': pointTextSize,
    'font-size': pointTextSize,
    'line-height': pointTextSize
});

//css style to create label outlines for improved readability
#printPreview span {
    text-shadow: -1px -1px 0 #FFF, 1px -1px 0 #FFF, -1px 1px 0 #FFF, 1px 1px 0 #FFF;
}

It’s not perfect, but the end result seems to be a readable printed Leaflet map. I hope this has given those of you who want to step outside of our media silos some ideas for further experimentation!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s