Loading GeoJSON geometry into IgniteUI igMap

Graham Murray / Friday, January 17, 2014

Introduction

I was asked recently if IgniteUI’s igMap supported the loading of GeoJSON data. The answer is that igMap supports loading geometry from a JavaScript object graph, and GeoJSON, when parsed or executed, also results in a JavaScript object graph, but the map expects the objects to be arranged in a subtly different way than you will receive them from GeoJSON.

The fact that the expectations are subtly different from what GeoJSON provides, though, does not present a difficult problem to surmount. In fact, it’s pretty simple to get things up and running. So, let’s do it!

TL;DR; version:

If you only want to know how to adapt some GeoJSON data for use with the symbol series please see the method flattenFeed below! Articles on how to adapt GeoJSON to fit polyline and shape series to follow.

Getting the Data

For this example, we will load earthquake data from the USGS, since they provide some convenient GeoJSON feeds for examining the locations of recent earthquakes.

The feed I decided to try was for all 4.5 magnitude and above earthquakes in the past month, which is located here: http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_month.geojsonp

We will use jQuery to make a jsonp AJAX call to retrieve this json data and parse it into a JavaScript object graph for our use. To do that we will make this call:

$.ajax({
        url: "http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_month.geojsonp",
        dataType: "jsonp",
        jsonpCallback: "eqfeed_callback",
        success: render
    });

Here we are performing the following:

  • Requesting that jQuery make an asynchronous call to retrieve the required GeoJSON feed from the USGS.
    • Note that the url ends with geojsonp, while most of the API doc on USGS will point you at geojson (with no p). The p at the end makes sure that USGS will respond with a result in jsonp format so that the appropriate callback method will be called when the json is loaded. For more details read up on jsonp.
  • Specifying to jQuery that we expect the response type to be jsonp.
  • Specifying what callback function jQuery will need to define in order that the jsonp request can notify us on load.
  • Specifying the callback function that we want called once the jsonp resource has been successfully downloaded and parsed for our use.

We will define this method later.

Creating the igMap

Next, lets create the igMap that will display the GeoJSON data once downloaded:

$("#map").igMap({
        width: "100%",
        height: "90%",
        horizontalZoomable: true,
        verticalZoomable: true,
        windowRectMinWidth: 0.001,
        overviewPlusDetailPaneVisibility: "visible"
    });

Here we are finding the DOM element with id “map” and initializing the igMap component within it with the following options:

  • The map will fill 100% of the width of the container.
  • The map will fill 90% of the height of the container.
  • The map can zoom in both directions.
  • The map currently does not have any series.
  • We are expressing the maximum zoom scale that the map can reach (Open Street Map imagery only goes so deep).
  • We are enabling the navigation pane in the corner of the map.

Note, we are not yet actually specifying any series content for the map. In this case, things are simpler if we wait until we have downloaded the required data, and then load some series content on demand.

Dynamically Adding a Series To igMap

Once the AJAX call has returned and we have our parsed GeoJSON data, we need to create a map series and bind it to that data. In this way we will make it possible to navigate through our data and interact with it. Above, we referred to a method called “render” as the success handler for the AJAX call. Let’s define that method now:

function render(res) {
        res = flattenFeed(res);

        $("#map").igMap("option", "series", [{
            type: "geographicSymbol",
            name: "earthquakes",
            title: "earthquakes",
            markerBrush: "red",
            markerOutline: "black",
            latitudeMemberPath: "latitude",
            longitudeMemberPath: "longitude",
            dataSource: res,
            showTooltip: true,
            tooltipTemplate: "<table><tr><td>Magnitude: </td><td>${item.properties.mag}</td></tr><tr><td>Place: </td><td>${item.properties.place}</td></tr><tr><td>Time: </td><td>${item.dateTimeString}</td></tr></table>"
        }]);
    }

In the above we:

  • Specified that the type of series that we want is the symbol series.
    • This series will place markers at the provided geographic coordinates.
    • We can style and customize these markers to convey other characteristics about the data.
    • We could also load polyline/polygon geometry from GeoJSON into some of the other available series types, but we’ll save that for next time.
  • Call a method that will rearrange certain aspects of the GeoJSON object graph to make it easier to load them into our desired series.
  • Provide a name and title to the series.
  • Set some colors to use for the earthquake markers.
  • Indicated which properties on our data items will contain the latitude and longitude information for the current series.
  • Assigned the data source we have flattened earlier in the method.
  • Assigned a tooltip that will display interesting information about the magnitude and location of the earthquake.

Given this, the only piece we are missing is how we have adapted the GeoJSON object graph to easily load it into the symbol series.

Adapting the GeoJSON Data

The symbol series in the map expects to be able to find two properties on each data item that represent the latitude and longitude position of each point. The GeoJSON format buries this information rather deep in the returned object graph, so one of the steps we will perform is to surface it to an easily discoverable place on each item.

Here is the definition of the flatten feed method:

function flattenFeed(res) {
        var curr;
        res = res.features;
        for (var i = 0; i < res.length; i++) {
            curr = res[i];
            if (curr.geometry && curr.geometry.type == "Point" && curr.geometry.coordinates) {
                curr.latitude = curr.geometry.coordinates[1];
                curr.longitude = curr.geometry.coordinates[0];
            }
            if (curr.properties && curr.properties.time) {
                curr.time = new Date(curr.properties.time);
                curr.dateString = curr.time.toLocaleDateString();
                curr.timeString = curr.time.toLocaleTimeString();
                curr.dateTimeString = curr.dateString + " " + curr.timeString;
            }
        }
        return res;
    }

In the above we:

  • Extract the features collection from the GeoJSON response. This is what holds the data we would like to bind to the series.
  • Check our assumptions to make sure we are being fed GeoJSON style point coordinates, and then copy them to be top level values on each data item.
  • Convert the integer time offsets associated with each earthquake to a readable string to refer to in our tooltips.
  • Return the resulting flattened collection.

Combining all of the above we have the following full code for the sample:

$(function () {
    var data = [];

    function flattenFeed(res) {
        var curr;
        res = res.features;
        console.log(res.length);
        for (var i = 0; i < res.length; i++) {
            curr = res[i];
            if (curr.geometry && curr.geometry.type == "Point" && curr.geometry.coordinates) {
                curr.latitude = curr.geometry.coordinates[1];
                curr.longitude = curr.geometry.coordinates[0];
            }
            if (curr.properties && curr.properties.time) {
                curr.time = new Date(curr.properties.time);
                curr.dateString = curr.time.toLocaleDateString();
                curr.timeString = curr.time.toLocaleTimeString();
                curr.dateTimeString = curr.dateString + " " + curr.timeString;
            }
        }
        data = res;
        return res;
    }

    function render(res) {
        res = flattenFeed(res);

        $("#map").igMap("option", "series", [{
            type: "geographicSymbol",
            name: "earthquakes",
            title: "earthquakes",
            markerBrush: "red",
            markerOutline: "black",
            latitudeMemberPath: "latitude",
            longitudeMemberPath: "longitude",
            dataSource: res,
            showTooltip: true,
            tooltipTemplate: "<table><tr><td>Magnitude: </td><td>${item.properties.mag}</td></tr><tr><td>Place: </td><td>${item.properties.place}</td></tr><tr><td>Time: </td><td>${item.dateTimeString}</td></tr></table>"
        }]);
    }

    $("#map").igMap({
        width: "100%",
        height: "90%",
        horizontalZoomable: true,
        verticalZoomable: true,
        series: [],
        windowRectMinWidth: 0.001,
        overviewPlusDetailPaneVisibility: "visible"
    });


    $.ajax({
        url: "http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_month.geojsonp",
        dataType: "jsonp",
        jsonpCallback: "eqfeed_callback",
        success: render
    });


});

Which produces this output:

You can visit the fiddle for this sample here.

Notice that if you hover over the markers, you will get more information about the earthquake in question:

Pretty neat, huh? This is up to date information from USGS that gets refreshed every 15 minutes. We could update our code to re-poll the service periodically and introduce additional points.

Tailoring Our Markers To the Data

Next, why don’t we improve the information slightly that we are conveying with our markers. Wouldn’t it be neat if we could increase the size based on the magnitude? The igMap actually includes a series that makes this very simple for you, but since it is currently in the preview quality band, instead, I will show you how to achieve this by providing a custom marker template for the symbol series.

First, lets track the minimum and maximum magnitude readings we are getting from the GeoJSON service. In this way, we will know how to decide on a size for the markers:

 var data = [], currMinMag, currMaxMag;

    function flattenFeed(res) {
        currMinMag = 100;
        currMaxMag = 0;
        var curr;
        res = res.features;
        console.log(res.length);
        for (var i = 0; i < res.length; i++) {
            curr = res[i];
            if (curr.geometry && curr.geometry.type == "Point" && curr.geometry.coordinates) {
                curr.latitude = curr.geometry.coordinates[1];
                curr.longitude = curr.geometry.coordinates[0];
            }
            if (curr.properties && curr.properties.time) {
                curr.time = new Date(curr.properties.time);
                curr.dateString = curr.time.toLocaleDateString();
                curr.timeString = curr.time.toLocaleTimeString();
                curr.dateTimeString = curr.dateString + " " + curr.timeString;
            }
            if (curr.properties && curr.properties.mag) {
                currMinMag = Math.min(currMinMag, curr.properties.mag);
                currMaxMag = Math.max(currMaxMag, curr.properties.mag);
            }
        }
        data = res;
        return res;
    }

This is a modified version of our flattenFeed function that will read out the magnitude value from each data item, and update the current seen max and min values. We’ll use these values in our custom marker template:

quakeTemplate = {
        measure: function (measureInfo) {
            var propMag = (measureInfo.data.item().properties.mag - currMinMag) / (currMaxMag - currMinMag),
            size = (14 + propMag * 30);
            measureInfo.width = size;
            measureInfo.height = size;
        },
        render: function (renderInfo) {
            var ctx = renderInfo.context,
                propMag = (renderInfo.data.item().properties.mag - currMinMag) / (currMaxMag - currMinMag),
                size = (14 + propMag * 30),
                halfSize = size / 2.0;

            if (renderInfo.isHitTestRender) {
                ctx.fillStyle = renderInfo.data.actualItemBrush().fill();
                ctx.beginPath();
                ctx.arc(renderInfo.xPosition, renderInfo.yPosition, halfSize, 0, 2.0 * Math.PI);
                ctx.closePath();
                ctx.fill();
                ctx.stroke();
            } else {
                ctx.globalAlpha = .3;
                ctx.fillStyle = "rbga(200,34,35,.35)";
                ctx.strokeStyle = "rgba(200,34,35,.89)";
                ctx.beginPath();
                ctx.arc(renderInfo.xPosition, renderInfo.yPosition, halfSize, 0, 2.0 * Math.PI);
                ctx.closePath();
                ctx.fill();
                ctx.stroke();
                ctx.globalAlpha = 1.0;

                ctx.fillStyle = "rgba(111,7,7,.84)";
                ctx.beginPath();
                ctx.arc(renderInfo.xPosition, renderInfo.yPosition, 3.5, 0, 2.0 * Math.PI);
                ctx.closePath();
                ctx.fill();
            }
        }
    };

If you don’t know much about the HTML5 canvas some of the above may seem arcane. Under the covers the map is doing most of its rendering in the canvas, and when you assign a custom marker template you are offering to intercede and render some content into the canvas for a marker based on the parameters that the map specifies to you. In the above we:

  • Respond to the map with a size our marker wants to be, when asked.
    • We calculate this based on where the magnitude of the current quake falls between the min and max value.
  • Respond to the map when our marker needs to be rendered by:
    • Determining the size we want for the marker as above (this could be pulled into a separate method).
    • Rendering a shape that represents the “hit area” of the marker so that the map knows when to display tooltips for the marker, if this is the hit test pass.
      • Note, a very particular color needs to be used when rendering the hit area.
    • Rendering two concentric circles with the outer radius size related to the magnitude.

The final piece is to assign that custom marker template to the series, so we change the series addition to look like this:

$("#map").igMap("option", "series", [{
            type: "geographicSymbol",
            name: "earthquakes",
            title: "earthquakes",
            markerBrush: "red",
            markerOutline: "black",
            markerTemplate: quakeTemplate,
            latitudeMemberPath: "latitude",
            longitudeMemberPath: "longitude",
            dataSource: res,
            showTooltip: true,
            tooltipTemplate: "<table><tr><td>Magnitude: </td><td>${item.properties.mag}</td></tr><tr><td>Place: </td><td>${item.properties.place}</td></tr><tr><td>Time: </td><td>${item.dateTimeString}</td></tr></table>"
        }]);

Put it all together and you get this:

Again, you can go here for the jsfiddle.

Lets Put In More Data!

Below is how we can display a much larger GeoJSON resource from USGS using the high density scatter series, which will use higher heat values to tell you where more quakes have been happening. You’ll see all we’ve really done it to change the series type and to switch to a much larger feed. Please note, that the GeoJSON file involved is actually quite large, so takes a while to download. You’ll see the status change from GeoJSON Downloading to Download Completed when the download has finished.

$(function () {
    var data = [], currMinMag = 0, currMaxMag = 10;

    function flattenFeed(res) {
        currMinMag = 10;
        currMaxMag = 0;
        var curr;
        res = res.features;
        console.log(res.length);
        for (var i = 0; i < res.length; i++) {
            curr = res[i];
            if (curr.geometry && curr.geometry.type == "Point" && curr.geometry.coordinates) {
                curr.latitude = curr.geometry.coordinates[1];
                curr.longitude = curr.geometry.coordinates[0];
            }
            if (curr.properties && curr.properties.time) {
                curr.time = new Date(curr.properties.time);
                curr.dateString = curr.time.toLocaleDateString();
                curr.timeString = curr.time.toLocaleTimeString();
                curr.dateTimeString = curr.dateString + " " + curr.timeString;
            }
            if (curr.properties && curr.properties.mag) {
                currMinMag = Math.min(currMinMag, curr.properties.mag);
                currMaxMag = Math.max(currMaxMag, curr.properties.mag);
            }
        }
        data = res;
        return res;
    }

    function render(res) {
        $("#downloading").text("Download Complete!");
        res = flattenFeed(res);

        $("#map").igMap("option", "series", [{
            type: "geographicHighDensityScatter",
            name: "earthquakes",
            title: "earthquakes",
            latitudeMemberPath: "latitude",
            longitudeMemberPath: "longitude",
            dataSource: res,
            mouseOverEnabled: true,
            showTooltip: true,
            pointExtent: 2,
            tooltipTemplate: "<table><tr><td>Magnitude: </td><td>${item.properties.mag}</td></tr><tr><td>Place: </td><td>${item.properties.place}</td></tr><tr><td>Time: </td><td>${item.dateTimeString}</td></tr></table>"
        }]);
    }

    $("#map").igMap({
        width: "100%",
        height: "90%",
        horizontalZoomable: true,
        verticalZoomable: true,
        series: [],
        windowRectMinWidth: 0.001,
        overviewPlusDetailPaneVisibility: "visible"
    });


    $.ajax({
        url: "http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.geojsonp",
        dataType: "jsonp",
        jsonpCallback: "eqfeed_callback",
        success: render
    });
    


});

Here’s the result:

And the associated fiddle.

Note, this series type can actually display MUCH more data. The above is roughly 7000 points, which is really on the low end of what you would be displaying with this series type. Unfortunately GeoJSON is pretty verbose, so a larger file would take even longer to download. Fortunately there are more compressed ways to get data to the map, but we’ll save that for another time!

-Graham