Friday, July 20, 2012

MapFish Print and Leaflet, Day 1

Today's rant (sorry, I mean constructive how-to) isn't about Leaflet at all, but about MapFish Print and getting it to work with Leaflet (well okay, it's kinda about Leaflet).

MapFish Print is a server component of the MapFish system, which creates PDFs containing maps. You create a YAML configuration file defining "layouts" such as paper size, an icon here, a border here, user-supplied text here, a map there, make a call to it via the "client spec" and get the URL of a PDF.

INSTALLATION

The installation docs didn't work out. The Java app compiled, and would run the help message. But when I tried to run it using the provided sample config, it failed. I posted to the MapFish users list to ask for help, but the only reply I got was from my own client, saying that I should use the GeoServer version of MapFish and that he had already installed it.

Hilarious, right? Fortunately I get really great clients, who don't think less of me for asking the list for help, who are in fact on that list, and who do things like install MapFish Print for me and run its example app to make sure it works. Thanks a million, man; you'll come across this post some day and know who you are and how much I appreciate clients like you. :)

CLIENT SPEC

To request a print, you send a "client spec" to MFP's URL. The client spec is a JSON document sent as a raw POST body, specifying the map layers, overlaid vector features, choice of layout, current bounding box (wrong! see below), et cetera. MapFish Print has a Control built into its bundled OpenLayers which can do this, but again this is Leaflet so I had to invent a method for adapting it.

The client spec has these components.
- Map scale. Leaflet doesn't have a scale calculation, I had to invent it.
- The SRS and map center. Catch: Using the native SRS (lon-lat) makes for very badly stretched-out maps in the PDF, so we really want to use Web Mercator.
- Choice of layout as defined in the config.yaml
- List of image layers
- List of vector features

Unfortunately, the documentation doesn't even mention the vector features; the only mention anywhere is a commit note saying "added markers and vectors" Fortunately, the demo app has a point, a line, and a polygon, and I know a thing or two about OGC specs, OpenLayers, and GeoJSON and figured it out.

Also, the client spec can take a "bbox" param rather than a center and scale. Unfortunately, this worked very badly: the map in the PDF isn't even close to the size of the average monitor, so using the same bounding box meant that the print version was 1 or 2 zoom levels off of what was displayed on the monitor. It's a common issue even when staying in the web browser: you just can't cram the same spatial rectangle onto two differently-sized browser window and expect the exact same thing, so you go with center-and-zoom so the difference is merely trimmed edges.

So, here are the relevant snips of code for doing each part.

1. Load Proj4JS so you can do reprojection from LatLng to Web Mercator, and load a JSON encoder since most browsers still don't have one built in.
    <script type="text/javascript" src="json.js"></script>
    <script type="text/javascript" src="proj4js/lib/proj4js-compressed.js"></script>
    <script type="text/javascript" src="proj4js/lib/projCode/utm.js"></script>
    <script type="text/javascript" src="proj4js/lib/projCode/merc.js"></script>
    <script type="text/javascript" src="proj4js/lib/defs/EPSG4326.js"></script>
    <script type="text/javascript" src="proj4js/lib/defs/EPSG900913.js"></script>

2. This little function reprojects a [x,y] array or a L.LatLng to Web Mercator, and returns a [x,y] array.
// reproject from WGS84 (Leaflet coordinates) to Web Mercator (primarily for printing)
// accepts a L.LatLng or a two-item array [lng,lat]    (note that array is X,Y)
// returns a two-item list:  [ x,y ]  in Web mercator coordinates
function wgsToGoogle(dot) {
    var srsin    = new Proj4js.Proj('EPSG:4326');
    var srsout   = new Proj4js.Proj('EPSG:900913');
    var newdot   = dot.lat ? new Proj4js.Point(dot.lng,dot.lat) : new Proj4js.Point(dot[0],dot[1]);
    Proj4js.transform(srsin,srsout,newdot);
    return  [ newdot.x, newdot.y ];
}

 3. Calculate the map scale. Note that we round to the nearest 250, since MFP requires that we define a finite set of scales. Note too that within a metropolitan area, there can be significant
drift in the scale, e.g. 10500 at the south end of town and 10250 at the north end of town. Watch your "Internal Server Error" text as you test.

    var center  = MAP.getCenter();
    var DOTS_PER_INCH    = 72;
    var INCHES_PER_METER = 1.0 / 0.02540005080010160020;
    var INCHES_PER_KM    = INCHES_PER_METER * 1000.0;
    var sw       = MAP.getBounds().getSouthWest();
    var ne       = MAP.getBounds().getNorthEast();
    var halflat  = ( sw.lat + ne.lat ) / 2.0;
    var midLeft  = new L.LatLng(halflat,sw.lng);
    var midRight = new L.LatLng(halflat,ne.lng);
    var mwidth   = midLeft.distanceTo(midRight);
    var pxwidth  = MAP.getSize().x;
    var kmperpx  = mwidth / pxwidth / 1000.0;
    var scale    = (kmperpx || 0.000001) * INCHES_PER_KM * DOTS_PER_INCH;
    scale *= 2.0; // no idea why but it's doubled
    scale = 250 * Math.round(scale / 250.0); // round to the nearest 1,000 so we can fit MapFish print's finite set of scales 
 4. Collect the photo tile layers. Possible improvements here would be to read the opacity instead of forcing 1.0, and to read the TileLayer's URL instead of hardcoding it here.
 
    var layers = [];
    if ( MAP.hasLayer(PHOTOBASE) ) {
        layers[layers.length] = { baseURL:"http://server.com/wms", opacity:1, singleTile:true, type:"WMS", layers:["photo"], format:"image/jpeg", styles:[""], customParams:{} };
    }
    if ( MAP.hasLayer(MAPBASE) ) {
        layers[layers.length] = { baseURL:"http://server.com/wms", opacity:1, singleTile:true, type:"WMS", layers:["topo"], format:"image/jpeg", styles:[""], customParams:{} };
    }
    for (var i=0, l=OVERLAYS.length; i<l; i++) {
        var layer = OVERLAYS[i];
        if (! MAP.hasLayer(layer) ) continue;
        var layernames = layer.options.layers.split(",");
        var opacity = 1.0;
        layers[layers.length] = { baseURL:"http://server.com/wms", opacity:opacity, singleTile:true, type:"WMS", layers:layernames, format:"image/png", styles:[""], customParams:{} };
    }
 
 5. Reproject the map center to Web Mercator.
 
    var projcenter = wgsToGoogle(MAP.getCenter());
 
6. Ready for our first tests!
 
    // compose the client spec for MapFish Print
    var params = {
        "units":"meters",
        "srs":"EPSG:900913",
        "layout":"Landscape",
        "dpi":300,
        "layers":layers,
        "pages":[
            { center:projcenter, scale: scale, rotation:"0" }
        ],
        "layersMerging" : false
    }; 
 
    // send it out over jQuery. Use a raw POST here, since $.post() will mangle the body content
    $.ajax({
        url: 'http://server.com/create.json', type:'POST',
        data: JSON.stringify(params), processData:false, contentType: 'application/json',
        success: function (reply) {
            var url = reply.getURL;
            window.open(url);
        }
    });
 
 
CONFIG YAML

I won't go into detail here; it's documented by the MapFish project, and doesn't really pertain to integrating with Leaflet.

Fortunately, the client also provided a config.yaml from a previous project. This saved me weeks of learning it, especially given MFP's very poor documentation as to what options exist.


2 comments:

  1. Hi Gregor,

    May you, please, clarify the 4th point?

    I do not totally understand what are you doing there.

    Thanks indeed.

    ReplyDelete
    Replies
    1. Do you mean "4. Collect the photo tile layers." ?

      MFP requires a "client spec" which lists (among other things) the list of map layers. Each layer is an object with keys such as baseUrl, format, styles, ...

      Here is an example client spec, showing a WMS layer, then three Vector layers, point, line, and polygon.

      {"baseURL":"http://labs.metacarta.com/wms/vmap0","opacity":1,"singleTile":false,"type":"WMS","layers":["basic"],"format":"image/jpeg","styles":[""],"customParams":{}},
      {"type":"Vector",
      "styles":{
      "1":{"externalGraphic":"http://openlayers.org/dev/img/marker-blue.png","strokeColor":"red","fillColor":"red","fillOpacity":0.7,"strokeWidth":2,"pointRadius":12}
      },
      "styleProperty":"_gx_style",
      "geoJson":{"type":"FeatureCollection",
      "features":[
      {"type":"Feature","id":"OpenLayers.Feature.Vector_52","properties":{"_gx_style":1},"geometry":{"type":"Polygon","coordinates":[[[15,47],[16,48],[14,49],[15,47]]]}},
      {"type":"Feature","id":"OpenLayers.Feature.Vector_61","properties":{"_gx_style":1},"geometry":{"type":"LineString","coordinates":[[15,48],[16,47],[17,46]]}},
      {"type":"Feature","id":"OpenLayers.Feature.Vector_64","properties":{"_gx_style":1},"geometry":{"type":"Point","coordinates":[16,46]}}]}
      ],
      "name":"vector","opacity":1}
      }

      So the idea is to construct this same structure, using my map configuration.

      The approach is: add either the photo basemap or else the map basemap (MAPBASE and PHOTOBASE are L.TileLayer instances), then iterate over my list of other overlay layers and add them to the list (the "styles" param is expected to be an array, but Leaflet uses a string. So, I do a split on commas).

      In my case, I keep the list of layers in OVERLAYS, so we can do tricks like this:
      OVERLAYS = [
      new L.TileLayer(...),
      new L.TileLayer(...),
      new L.TileLayer(...),
      new L.TileLayer(...)
      ];

      I hope that helps.

      Delete