Can you rotate and pinch the SVG map?

As I am writing the blog you might have guessed that answer is going to be yes. I mean not only drag, pinch and rotation but also retrieving map’s view (center coordinate, zoom level/ resolution and rotation angle) after applying many touch operations. So I want to create all maps navigation features and along with handling of maps view state in maps coordinate system. In some of my previous articles like Thematic map with geojson2svg and Interactive map for data visualization I have explained how easily we can create the SVG maps with GeoJSON data. In this article I’ll implement emptymap.js with Hammer.js to achieve maps navigation.

Maps navigation is composition of two concepts, screen interaction and maps rendering. Screen interactions are pinch, drag, double tap and rotation. The second part is maps rendering according to the screen interaction like double tap as zoom in and drag as pan. Hammer.js is wonderful library for screen interactions, it support all touch interactions/gestures with appropriate measurements like drag/pan as movement in pixels in x and y direction. So given movement in pixels how much should the map be shifted? For this purpose I have developed the emptymap.js module. As the name suggests it does not contain the maps rather helps in maps navigation. emptymap.js helps in calculating the transformation matrix for given drag movement or other interactions measurement. This transformation matrix can be set to SVG as CSS transform property or SVG attribute to achieve the interaction effect on maps. emptymap.js also maintains the maps current state so at any point of time you can retrieve the maps view values center, zoom level/resolution and rotation.

Before discussing the code in detail let’s pinch and rotate this SVG map:

 View 

+
-

  • Population map
  • <= 30m
  • > 30m and <= 60m
  • > 60m
Use this map to emulate touch for desktop.

Here is the HTML component of the map:

<div id="mapArea" >
  <div class="pannel"> 
    <div id="zoomin" class="icon"><span>+</span></div>
    <div id="zoomout" class="icon"><span class="left-margin">-</span></div>
  </div> 
  <div id="viewPort" >
      <svg id="map" xmlns="http://www.w3.org/2000/svg" 
        x="0" y="0" width="100%" height="100%">
        <g id="worldpop"></g>
      </svg>
    </div>
  </div>
</div>

The viewport div would be used to capture the screen interaction with Hammer.js and the group worldpop in svg would be used to draw the SVG map. Hammer.js instance would calculate the measurement of interaction in viewport and instance of emptymap.js would be used to calculate the transformation matrix.

Seems simplified as we have separated the interaction part and maps calculations, for interaction part any other library can also be used. I’ll focus here in detail the implementation of emptymap.js. Complete code base is available on github and this is the main JavaScript file for the map. We’ll discuss all important functions/code now.

First we need to set map’s view i.e. setting the map’s center, zoom and rotation. emptymap.js has this facility or function to set desired view. Another critical thing is rendering the geojson data on SVG that I achieve with geojson2svg, check my previous articles. drawGeoJSON function is the function that does maps rendering.

Now setting the map’s initial view:

 10 vp = document.getElementById('viewPort');
 11 size = {width: vp.offsetWidth, height: vp.offsetHeight};
 12 emap = new emptyMap(size);
 13 emap.setView({
 14   view: {"center":[1104009.9356444478,4736381.1012214925],"zoom":2,"rotation":-20},
 15   callback: function(err,state) {
 16     if(err) {
 17       console.log('setview err: '+ err);
 18       return;
 19     }
 20     var svgLayer = document.getElementById('worldpop');
 21     svgLayer.setAttribute('transform', 'matrix('+state.matrix.join(', ')+')');
 22     //show maps current view above map
 23     document.getElementById('viewJSON').innerHTML = JSON.stringify(
 24       state.map.getView(),null, 2);
 25     // get countires geojson data and population data
 26     $.when(
 27       $.getJSON('./data/countries.geo.json'),
 28       $.getJSON('./data/population.json')
 29     ).then(drawGeoJSON, function() {
 30       console.log('data not found');
 31     })
 32   }
 33 });

First we initiate emptymap.js instance by passing the size of viewport div. In any function of emptymap.js pixel coordinates should be with respect to viewport, read API of emptymap.js in detail. You can also pass the projection extent if projection is other than spherical mercator. To set the initial map’s view .setView function is used and parameters are passed as object. There are wo parameters in the passed object, one is obviously the view itself whereas center coordinates are in projected coordinate system and rotation is degrees clock wise positive. The second parameter is callback function, lets understand this clearly. The function gets passed two arguments first error object and second is the map’s state after setting the view. The state argument is:

{
  matrix: array of 6 transformation coefficients for svg map
  tileMatrix: array of 6 transformation coefficient for tile map
  map: reference to the map itself
}

So matrix key has the matrix transformation values. We attach this matrix’ values to worldPop group of SVG as transform property and the svg group is scaled, rotated and translated to desired position. For any other interaction function of emptymap.js also we need to pass callback function. And that function get passed same error and map’s state, so we can make maps state handler as a function:

 41 function handleMapState(err,state) {
 42   if(err) {
 43     console.log('map state error: '+ err);
 44     return;
 45   }
 46   var svgLayer = document.getElementById('worldpop');
 47   svgLayer.setAttribute('transform','matrix('+state.matrix.join(', ')+')');
 48   //show maps current view above map
 49   document.getElementById('viewJSON').innerHTML = JSON.stringify(
 50     state.map.getView(),null, 2);
 51 }

Hammerjs’ event object has event (interaction) center coordinates relative to the upper-left corner of the browser’s client area. That need to be converted as relative to map’s viewprot. getEventCenterPx is used for that.

 34 function getEventCenterPx(ev) {
 35   var viewPort = document.getElementById('viewPort');
 36   return [
 37     ev.center.x - viewPort.getBoundingClientRect().left,
 38     ev.center.y - viewPort.getBoundingClientRect().top];
 39 }

Now we’ll check how the interactions or gestures are handled with Hammerjs. There is nice documentation also available for Hammerjs. Hammer is quite flexible library to handle the screen as well as mouse interaction. First create Hammer instance by passing the map’s viewport div then attach event handler.

Now lets look at the panning of map. First we initiate the Hammer instance and add pan gesture.

 54 var viewPort = document.getElementById('viewPort');
 55 var mc = new Hammer.Manager(viewPort);
 56 mc.add( new Hammer.Pan({
 57   direction: Hammer.DIRECTION_ALL,
 58   threshold: 0,
 59   pointers: 1,
 60   preventDefault: true
 61 }) );

Now the pan event’s handlers are:

 62 var lastDelta = {x: 0, y: 0};
 63 mc.on('panstart', function(ev) {
 64   console.log('panstart');
 65   lastDelta = {x: 0, y: 0};
 66 });
 67 mc.on('pan', function(ev) {
 68   var cont = document.getElementById('container');
 69   emap.applyDeltaMove({
 70     deltaX: ev.deltaX - lastDelta.x,
 71     deltaY: ev.deltaY - lastDelta.y,
 72     callback: handleMapState
 73   });
 74   lastDelta.x = ev.deltaX;
 75   lastDelta.y = ev.deltaY;
 76 });

In Hammer callback’s event(ev) object deltaX and deltaY are from starting of the pan event. But we want to apply the continues pan so I store the last delta while pan is in action and sort of recalculating the delta move (for x: ev.deltaX - lastDelta.x) that has not been applied to map. So now implementation is simple just pass deltaX, deltaY and map state handler (handleMapState) to .applyDeltaMove API and your map is pan enabled for touch screen (mobile, tab etc) or desktop.

Aren’t we excited, what about zoom in on double tap. Not difficult, use .applyDeltaScaleRotation API, pass the tapped position on viewport and scale factor. In case of zoom in scale factor should be 2. Finally our callback handleMapState will set the zoomed in map.

 79 mc.add(new Hammer.Tap({event: 'doubletap',taps: 2}));
 80 mc.on('doubletap', handleDoubleTap);
 81 function handleDoubleTap(ev) {
 82   console.log('doubletapped');
 83   var tapX = ev.center.x - viewPort.getBoundingClientRect().left;
 84   var tapY = ev.center.y - viewPort.getBoundingClientRect().top;
 85   emap.applyDeltaScaleRotation({
 86     position: [tapX, tapY],
 87     factor: 2,
 88     callback: handleMapState
 89   });
 90 }

Now we are too excited lets pinch and rotate the map. For pinch and rotation to work simultaneously there is function in Hammer .recognizeWith and used like this:

 92 var pinch = new Hammer.Pinch();
 93 var rotate = new Hammer.Rotate(
 94   {event:'rotate',pointers:2,threshold: 0});
 95 pinch.recognizeWith(rotate);
 96 mc.add(pinch);
 97 mc.add(rotate);

Like lastDelta for pan, lastScale and lastRot are used to store the intermediate scale during pinch and rotation respectively. Pinch and rotation both are handled by .applyDeltaScaleRotation API of emptymap.js. In case of pinch pass scale factor to which the map would be zoomed in or zoomed out. And in case of rotation we pass the rotation angle in degrees clockwise positive. Again handleMapState callback function set the map’s state to SVG map. Check the detailed code for pinch and rotation interaction/gesture handler here.

Now I think for zoom in/out buttons you can easily guess what should be the click event handler function. Yes its .applyDeltaScaleRotation with scale factor 2 for zoom in and scale factor 0.5 for zoom out.

I hope your SVG map is navigable now with pinch, drag and rotation.

Note: Each article in this blog is an individual project. Here is the source code for this article’s map.