Javascript

An Interactive Map with Leaflet, GeoJSON, and jQuery Using Bootstrap

A Side Project – An Interactive Parking Map of Innsbruck

When I recently moved to Innsbruck, I noticed that there was no interactive map for the parking system available. The amount of time you can park your car depends on the zone your car is located in. There are 20 parking zones and they are defined by their bordering streets in the city.  Innsbruck is very dense and parking is always a hot topic. So I thought having an interactive map makes it easier to find zones where you can leave your car longer, also if your are not so familiar with the street names. The city of Innsbruck offers the GIS data at the open data portal of Austria, which made it quite easy to implement such a map. I used the following technologies for creating this map:

The source code is available at my Github account. The implementation is available here and also at the Austrian Open Data Portal.

Code Snippets

Explaining the whole source code would be a bit too much for this post and most of it is pretty self explanatory, but in the following section I would like to highlight a few things of the project.

Initializing

In the top of the HTML file, we load the JavaScript file which contains all the functions and variables for our implementation. The script is called parkraum.js (parkraum means parking space in German).

The initialization of the Javascript code is straight forward with jQuery. I structured the initialization into a few components, as you can see in the following example.

<script>
    $(document).ready(function() {
	initMap();
	placeZonesOnMap();
	loadScrolling();
	populateParkzoneDropdown();
    });
</script>

We first initialize the Map itself, then place the parking zones, initialize the page scrolling to make it more smoothly and then populate the drop down menu with the available parking zones.

Initialize the Map

The first step is the initialization of the map with Leaflet. Note that map is a global variable. The coordinates [47.2685694, 11.3932759] are the center of Innsbruck annd 14 is the zoom level. In order to offer the map on a public page, you need to register with mapbox, a service which provides the tiles for the map. Mapbox is free for 50k map views per month. Make sure to use your own key and show some attribution.

// Initialize map and add a legend
function initMap() {
    map = L.map('map').setView([47.2685694, 11.3932759], 14);
    tileLayer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token=YOUR_API_KEY, {
        maxZoom: 18,
        attribution: 'Map data &copy; <a href="http://openstreetmap.org">OpenStreetMap&lt;/a&gt; contributors, ' +
            '<a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA&lt;/a&gt;, ' +
            'Imagery © <a href="http://mapbox.com">Mapbox&lt;/a&gt;',
        id: 'mapbox.light'
    }).addTo(map);

    addLegend();
}

We also add a legend to the map, for indicating the parking area type with colors. There exist 4 types of parking areas and we just add little squares with that colors to the map. The legend improves the readability of the map.

/* Add a legend to the map. */
function addLegend(){
    var legend = L.control({position: 'bottomright'});
    legend.onAdd = function (map) {

    var div = L.DomUtil.create('div', 'info legend');
    div.innerHTML =
        '<i style="background:#72B2FF";>&lt;/i&gt;&lt;span style="font-weight: 600;"&gt;90 Minuten Kurzparkzone&lt;/span&gt;&lt;br&gt;' +
        '<i style="background:#BEE7FF";>&lt;/i&gt;&lt;span style="font-weight: 600;"&gt;180 Minuten Kurzparkzone&lt;/span&gt;&lt;br&gt;' +
        '<i style="background:#A3FF72";>&lt;/i&gt;&lt;span style="font-weight: 600;"&gt;Werktags Parkstraße&lt;/span&gt;&lt;br&gt;' +
        '<i style="background:#D8D0F4";>&lt;/i&gt;&lt;span style="font-weight: 600;"&gt;Parkstraße&lt;/span&gt;&lt;br&gt;';

    return div;
};

legend.addTo(map);
}

The final map with the legend looks like this:

Reading Data, Adding Layers

I obtained the shapefiles with the geographic information from the Austrian Open Data Portal. The data seems to be exported from ArcGIS and I manually converted it into GeoJSON, which is directly supported by Leaflet.js without any plugins. To do this, I just copied the polygon data into the GeoJSON structure. I also separated the quite large file into smaller junks, each parking zone in one file.

Below you can see an example how the JSON looks like for the parking zone C. The structure contains the short name (“C”), some additional information about the zone, attributes for the color, opacity and outline and of course the actual coordinates, which make up a polygon covering the parking area.

{
    "type": "Feature",
    "properties": {
        "parkzonenKuerzel": "C",
        "parkzoneInfo":"Kurzparkzone 90 min gebührenpflichtig, werktags Mo-Fr von 9-21 Uhr und Sa von 9-13 Uhr, ½ Stunde EUR 0.70, 1 Stunde EUR 1.40, 1½ Stunden EUR 2.10.",
        "style": {
            "weight": 2,
            "color": "#999",
            "opacity": 1,
            "fillColor": "#72B2FF",
            "fillOpacity": 0.5
        }
    },
    "geometry":{
        "type":"Polygon",
        "coordinates":[
            [
                [11.389460535000069,47.261162269000067],[11.389526044000036,47.261021078000056],
                ...
          ]
        ]
    }
}

I created a small object containing the name of a parking zone, the relative path of the JSON file and a place holder for layer information.

var parkzonen = [{
    parkzone: 'C',
    jsonFile: './data/zoneC.json',
    layer: ''
}, {
    parkzone: 'D',
    jsonFile: './data/zoneD.json',
    layer: ''
}
...
}];

This is globally available inside the JS file. The actual loading of the parking zones and the placement of the polygones on the map is happening in the following function. It uses jQuery to load the JSON files. Note that jQuery expects the files to be delivered by a Web server. So in order for this to work, you need to make sure that the files can be served by a Web server , also on your local development machine. You can try this very easy, if you execute the following python statement within the root directory of your local development directory:``` sudo http-server -p 80  .

function placeZonesOnMap() {
    for (var zone in parkzonen) {
        var parkzonenKuerzel = parkzonen[zone].parkzone;
        var jsonURL = parkzonen[zone].jsonFile;
        $.ajaxSetup({
            beforeSend: function(xhr) {
                if (xhr.overrideMimeType) {
                    xhr.overrideMimeType("application/json");
                }
            }
        });

        $.getJSON(jsonURL, function(data) {

            placeZoneOnMap(data);

        });

    }
}
// Place zone on map
function placeZoneOnMap(data) {
    var parkzonenKuerzel = data.properties.parkzonenKuerzel;


    for (var zone in parkzonen) {
        if(parkzonen[zone].parkzone===parkzonenKuerzel){
            var layer = addGeoJSONToMap(data);
            parkzonen[zone].layer= layer;
        }
    }
}

function addGeoJSONToMap(data){
    var layer = L.geoJson([data], {
        style: function(feature) {
            return feature.properties && feature.properties.style;
        },
        onEachFeature: onEachFeature,
    }).addTo(map);
    return layer;
}

function onEachFeature(feature, layer) {
    var popupContent = 'Parkzone: <span style="font-weight: 900; font-size: 150%;">' + feature.properties.parkzonenKuerzel + '&lt;/span&gt;&lt;/br&gt;' + feature.properties.parkzoneInfo;

    layer.bindPopup(popupContent);
    var label = L.marker(layer.getBounds().getCenter(), {
        icon: L.divIcon({
            className: 'label',
            html: '<span style="font-weight: 900; font-size: 200%;color:black;">' + feature.properties.parkzonenKuerzel+'&lt;/span&gt;',
            iconSize: [100, 40]
        })
    }).addTo(map);
}```


The for loop iterates over the object, where we stored all the parking zones, respectively the JSON file locations. We load the files, one by one, and place the polygons on the map. This of course works in an asynchronous fashion. After this step, the polygons become visible on the map. We also add a clickable info box on all parking zones, which then display additional information.

## Dropdown Selection for Marking and Highlighting a Parking Zone

Users are able to select one of the parking zones from a drop down list.  In the first step, we add all parking zones by iterating over the parking zones object. Once selected, the parking zone will change the color and therefore be highlighted. To do that, we remove the layer of the parking zone and add it again in a different color.

function populateParkzoneDropdown(){ $('#selectParkzone’).empty(); $('#selectParkzone’).append($(‘</option>').val(‘Bitte Auswählen’).html(‘Zonen’)); $.each(parkzonen, function(i, p) { $('#selectParkzone’).append($(‘</option>').val(p.parkzone).html(p.parkzone)); });

}

$("#selectParkzone”).change(function () { var selectedParkZone = $("#selectParkzone”).val(); changeParkzoneColor(selectedParkZone);

});

function changeParkzoneColor (selectParkzone){ resetMap();

for (var zone in parkzonen) {
    if(parkzonen[zone].parkzone==selectParkzone){
        var layer = parkzonen[zone].layer;

        map.removeLayer(layer);
        layer.setStyle({
            fillColor: 'red',
            fillOpacity: 0.7
        });
        map.addLayer(layer);
    }
}

}

// Reset all layers function resetMap(){     console.log(‘reset’);     map.eachLayer(function (layer) {         map.removeLayer(layer);     });     map.addLayer(tileLayer);     placeZonesOnMap(); }



## Show the Current Location

Showing the current location is also a nice feature. By clicking on a button, the map scrolls to the current location, which is transmitted by the browser. Note that this only works if you deliver your pages with HTTPS!

function currentPosition() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(function(position) { latit = position.coords.latitude; longit = position.coords.longitude; //console.log(‘Current position: ' + latit + ' ' + longit); //alert(‘Current position: ' + latit + ' ' + longit); // this is just a marker placed in that position var abc = L.marker([position.coords.latitude, position.coords.longitude]).addTo(map); // move the map to have the location in its center map.panTo(new L.LatLng(latit, longit)); }); } }```

The single page app utilises bootstrap for rendering the content nicely and providing the navigation features. The following code snippet show how we can make all links scroll smoothly.

// use the first element that is "scrollable"
function scrollableElement(els) {
    for (var i = 0, argLength = arguments.length; i < argLength; i++) {
        var el = arguments[i],
            $scrollElement = $(el);
        if ($scrollElement.scrollTop() > 0) {
            return el;
        } else {
            $scrollElement.scrollTop(1);
            var isScrollable = $scrollElement.scrollTop() > 0;
            $scrollElement.scrollTop(0);
            if (isScrollable) {
                return el;
            }
        }
    }
    return [];
}

// Filter all links 
function filterPath(string) {
    return string
        .replace(/^\//, '')
        .replace(/(index|default).[a-zA-Z]{3,4}$/, '')
        .replace(/\/$/, '');
}

function loadScrolling() {
    var locationPath = filterPath(location.pathname);
    var scrollElem = scrollableElement('html', 'body');

    $('a[href*=\\#]').each(function() {
        var thisPath = filterPath(this.pathname) || locationPath;
        if (locationPath == thisPath &&
            (location.hostname == this.hostname || !this.hostname) &&
            this.hash.replace(/#/, '')) {
            var $target = $(this.hash),
                target = this.hash;
            if (target) {
                var targetOffset = $target.offset().top;
                $(this).click(function(event) {
                    event.preventDefault();
                    $(scrollElem).animate({
                        scrollTop: targetOffset
                    }, 400, function() {
                        location.hash = target;
                    });
                });
            }
        }
    });
}

Calling Back: An Example for Google’s Geocoder Service and Custom Markers

I recently moved and naturally there was a lot of clothes which I do not need (read: do not fit in) any more. Throwing them away would be a waste and luckily, there is a social business called WAMS which (besides a lot of other nice projects) supports reuse and recycling. WAMS provides and maintains containers for collecting clothes on many locations in Tirol. Unfortunately, there is not yet a map available to find them easily. I took this as an opportunity for a little side project in Javascript. I am not affiliated with WAMS, but of course the code and data is open sourced here.

Idea

The idea was quite simple. I used some of the container addresses I found in the flyer and created a custom Google Map showing the locations of the containers. The final result looks like this and a live demo can be found at the Github page.

Retrieve Geolocation Information

The Google API allows to retrieve latitude and longitude data from any given address. If the address was found in Google’s database, the Server returns a GeocoderResult object containing the geometry information about the found object. This GeocoderGeometry contains the latitude and longitude data of the address. The first step retrieves the data from Google’s API by using the Geocoder class. To do so, the following JSON structure is iterated and the addresses are being fed to the Geocoding service.

{
                "containerStandorte": [
                    {
                        "id": "1",
                        "name": "Pfarre Allerheiligen",
                        "address": "St.-Georgs-Weg 15, 6020, Innsbruck, Austria",
                        "latitude": "",
                        "longitude: "":
                    },
                    {
                        "id": "2",
                        "name": "Endhaltestelle 3er Linie Amras",
                        "address": "Philippine-Welser-Straße 49, 6020, Innsbruck, Austria",
                        "latitude": "",
                        "longitude": ""
            }

The Javascript code for obtaining the data is shown in the following listing:

window.onload = function() {

    // Data
    var wamsData =
 '{ "containerStandorte" : [' +
    '{ "id":"1", "name":"Pfarre Allerheiligen" , "address":"St.-Georgs-Weg 15, 6020, Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"2", "name":"Endhaltestelle 3er Linie Amras" , "address":"Philippine-Welser-Straße 49, 6020, Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"3", "name":"DEZ Einkaufszentrum Parkgarage" , "address":"Amraser-See-Straße 56a,6020 Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"4", "name":"Wohnanlage Neue Heimat" , "address":"Geyrstraße 27-29, 6020 Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"5", "name":"MPREIS Haller Straße" , "address":"Hallerstraße 212, 6020 Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"6", "name":"Recyclinginsel Novapark" , "address":"Arzlerstraße 43, 6020 Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"7", "name":"Höhenstraße / Hungerburg (neben Spar)" , "address":"Höhenstraße 125,6020 Innsbruck, 6020, Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"8", "name":"Recyclinginsel Schneeburggasse" , "address":"Schneeburggasse 116, 6020 Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"9", "name":"MPreis Fischerhäuslweg 31" , "address":"Fischerhäuslweg 31, 6020 Innsbruck, Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"10", "name":"Pfarre Petrus Canisius" , "address":"Santifallerstraße 5,6020 Innsbruck Austria" , "latitude":"", "longitude":"" },' +
    '{ "id":"11", "name":"MPreis Bachlechnerstraße" , "address":"Bachlechnerstraße 46, 6020 Innsbruck" , "latitude":"", "longitude":"" }'
    +' ]}';

    // Google Geocoder Library
    var geocoder = new google.maps.Geocoder();
    // Parse the JSON string into a javascript object.
    var wamsJSON = JSON.parse(wamsData);


    /**
        Iterate over containers and retrieve the geo location for their address.
    */
    function processContainers(){
        // Store amount of containers
        var amountOfContainers = wamsJSON.containerStandorte.length;
        // Iterate over all containers
        for (var i=0;i<amountOfContainers;i++){
            var container = wamsJSON.containerStandorte[i];
            // Encode the address of the container
            geocodeAddress(container, processContainerLocationCallback);
        };
    };

    /**
        Process the results
    */
    function processContainerLocationCallback(container,lat,long){
        wamsJSON = updateJSON(container,lat,long, printJSONCallback);
    }

    /**
        Update the JSON object and store the latitude and longitude information
    */
    function updateJSON(container,lat,long,printJSONCallback){
        // Store amount of containers
        var amountOfContainers = wamsJSON.containerStandorte.length;
        // Iterate over containers
        for (var i=0;i<amountOfContainers;i++){
            // Pick the correct id and store the data
            if(wamsJSON.containerStandorte[i].id==container.id){
                wamsJSON.containerStandorte[i].latitude=lat;
                wamsJSON.containerStandorte[i].longitude=long;
            }
        };
        // When the update is done, call the displayCallback
        printJSONCallback();
        return wamsJSON;
    };

    /*
        Google's Geocoder function takes and address as input and retrieves
        (among other data) the latitude and longitude of the provided address.
        Note that this is an asynchronous call, the response may take some time.
        Also remember that the processContainerLocationCallback which is given as
        an input parameter is just a variable. A variable which happens to be a function.

    */
    function geocodeAddress(container, processContainerLocationCallback){
        var address = container.address;
        geocoder.geocode( { 'address': address}, function(results, status) {
            // Anonymous function to process results.
            if (status == google.maps.GeocoderStatus.OK) {
                lat=results[0].geometry.location.lat();
                long=results[0].geometry.location.lng();
                // When the results have been retrieved,process them in the function processContainerLocationCallback
                processContainerLocationCallback(container, lat,long);
            } else {
                alert("Geocode was not successful for the following reason: " + status);
            }
        });
    };

    // Print the result
    function printJSONCallback(){
        var jsonString = JSON.stringify(wamsJSON, null,4);
        console.log(jsonString);
        document.getElementById("jsonOutput").innerHTML = jsonString;
    }

    // Start processing
    processContainers();
}

As the calls to the Google Services asynchronously, we need to use callbacks which are called when the function before has finished. Callbacks can be tricky and are a bit of a challenge to understand the first time. Especially the Google Geocoder methods require to work with several callbacks, which is often referred to as callback hell. The code above does the following things:

  1. Iterate over the JSON structure process each container individually -> function processContainers()
  2. For each container, call Google’s Geocoder and resolve the address to a location -> geocodeAddress(container, processContainerLocationCallback)
  3. After the result has been obtained, process the result. -> processContainerLocationCallback(container,lat,long)
  4. Update the JSON object by looping over all records and search for the correct id. Once the id was found, update latitude and longitude information. -> updateJSON(container,lat,long,printJSONCallback)
  5. Write the result to the Web page -> printJSONCallback()

The missing latitude and longitude values are retrieved and the JSON gets updated. The final result looks like this:

{
                "containerStandorte": [
                    {
                        "id": "1",
                        "name": "Pfarre Allerheiligen",
                        "address": "St.-Georgs-Weg 15, 6020, Innsbruck, Austria",
                        "latitude": 47.2680316,
                        "longitude": 11.355563999999958
                    },
                    {
                        "id": "2",
                        "name": "Endhaltestelle 3er Linie Amras",
                        "address": "Philippine-Welser-Straße 49, 6020, Innsbruck, Austria",
                        "latitude": 47.2589929,
                        "longitude": 11.42600379999999
                    }
                    ...
            }

Now that we have the data ready, we can proceed with the second step.

Placing the Markers

I artistically created a custom marker image which we will use to indicate the location of a clothes container from WAMS.

This image replaces the Google standard marker. Now all that is left is that we iterate over the updated JSON object, which now contains also the latitude and longitude data and place a marker for each container. Note that hovering over the image displays the address of the container on the Map.

// Data for container locations
        var wamsData = '{"containerStandorte":[{"id":"1","name":"Pfarre Allerheiligen",
"address":"St.-Georgs-Weg 15, 6020, Innsbruck, Austria","latitude":47.2680316,"longitude":11.355563999999958},
{"id":"2","name":"Endhaltestelle 3er Linie Amras","address":"Philippine-Welser-Straße 49, 6020, Innsbruck, Austria","latitude":47.2589929,"longitude":11.42600379999999},
{"id":"3","name":"DEZ Einkaufszentrum Parkgarage","address":"Amraser-See-Straße 56a,6020 Innsbruck, Austria","latitude":47.2625925,"longitude":11.430842299999995},
{"id":"4","name":"Wohnanlage Neue Heimat","address":"Geyrstraße 27-29, 6020 Innsbruck, Austria","latitude":47.2614899,"longitude":11.426765700000033},
{"id":"5","name":"MPREIS Haller Straße","address":"Hallerstraße 212, 6020 Innsbruck, Austria","latitude":47.2769524,"longitude":11.442559599999981},
{"id":"6","name":"Recyclinginsel Novapark","address":"Arzlerstraße 43, 6020 Innsbruck, Austria","latitude":47.2833947,"longitude":11.424273299999982},
{"id":"7","name":"Höhenstraße / Hungerburg (neben Spar)","address":"Höhenstraße 125,6020 Innsbruck, 6020, Innsbruck, Austria","latitude":47.2841353,"longitude":11.394666799999982},
{"id":"8","name":"Recyclinginsel Schneeburggasse","address":"Schneeburggasse 116, 6020 Innsbruck, Austria","latitude":47.2695889,"longitude":11.364059699999984},
{"id":"9","name":"MPreis Fischerhäuslweg 31","address":"Fischerhäuslweg 31, 6020 Innsbruck, Austria","latitude":47.261875,"longitude":11.364496700000018},
{"id":"10","name":"Pfarre Petrus Canisius","address":"Santifallerstraße 5,6020 Innsbruck Austria","latitude":47.2635626,"longitude":11.380990800000063},
{"id":"11","name":"MPreis Bachlechnerstraße","address":"Bachlechnerstraße 46, 6020 Innsbruck","latitude":47.2645067,"longitude":11.376220800000056}]}';


        function initialize() {
          var innsbruck = { lat: 47.2656733, lng: 11.3941983 };
          var map = new google.maps.Map(document.getElementById('map'), {
            zoom: 14,
            center: innsbruck
          });

          // Load Google Geocoder Library
          var geocoder = new google.maps.Geocoder();
          // Parse the data into a JSON
          var wamsJSON = JSON.parse(wamsData);
          // Iterate over all containers
          for(var i =0; i < wamsJSON.containerStandorte.length;i++){
              var container = wamsJSON.containerStandorte[i];
              placeMarkerOnMap(geocoder, map, container);
          };
        }

    // Custom marker
    var wamsLogo = {
      url: 'images/wams.png',
      // This marker is 20 pixels wide by 32 pixels high.
      size: new google.maps.Size(64, 64),
      // The origin for this image is (0, 0).
      origin: new google.maps.Point(0, 0),
      // The anchor for this image is the base of the flagpole at (0, 32).
      anchor: new google.maps.Point(64, 70)
    };

    // Define the shape for the marker
    var shape = {
      coords: [1, 1, 1, 64, 64, 64, 64, 1],
      type: 'poly'
    };

    // Place marker on the map
    function placeMarkerOnMap(geocoder, resultsMap, container) {
        // Create Google position object with latitude and longitude from the container object
        var positionLatLng = new google.maps.LatLng(parseFloat(container.latitude),parseFloat(container.longitude));
        // Create marker with the position, logo and address
        var marker = new google.maps.Marker({
          map: resultsMap,
          position: positionLatLng,
          icon: wamsLogo,
          shape: shape,
          title: container.address,
          animation: google.maps.Animation.DROP
        });
    }
    // Place marker on the map
    google.maps.event.addDomListener(window, 'load', initialize);

Hosting the Result

Github offers a great feature for hosting simple static Web pages. All is needed is a new orphan branch of your project, which is named gh-pages, as described here. This branch serves as the Web directory for all your files and allows to host Web pages for public projects for free. You can see the result of the project above here.