Monday, January 27, 2014

ArcGIS API, Highcharts, and kickbutt elevation profiles - 4. Charting elevation data with Highcharts

This posting is #4 (and likely the final) in a series. Using the ArcGIS JavaScript API I made a really nice demo of dynamic elevation charts with cool mouse effects and all, and this series has been a tutorial/walkthrough from first principles up to geoprocessing. Now we're up to the fun stuff: charting the elevation using Highcharts, and adding cool interactive mouse effects. You really should read the previous posts, at also read up on the ArcGIS JavaScript API


Massaging The Elevation Data


The previous post left us with a "path" of our elevation data. This is a list of [x,y,z] tuples, each one being a map coordinate and elevation. Next step is to massage that list somewhat, to make a nice chart.

The X axis of an elevation profile for a trail, would be the distance that we have traveled by the time we reach that segment. The first segment of the path would have 0 mileage (X=0), and the 14th segment's mileage would be the sum of the lengths of the first 13 segments (X=2.5 for example, that being in miles).

The Y axis would be the elevation. We received the elevation in meters and will convert to feet (if you prefer meters, skip the conversion, right?).

At each point we also want a tooltip (Highcharts is cool that way) and that tooltip could tell us the elevation delta from our starting location, e.,g. "Elevation: 1234 ft, +14 ft relative to start"
// input: a Path given to us from the elevation service geoprocessor: a list of tuples, each one being X/Y/meters
// return: a list of points for charting and in USA units: { lon, lat, elevft, text, miles }
// this function does massaging to the input data, such as converting the elevation from meters to feet (we're in the USA)
// and adding up lengths to figure the distance traveled at the end of each segment; the length in miles is effectively the X axis of the chart
// added bonus: it also calculates the elevation delta from your start, which looks very good in tooltips
function makeElevationProfileFromPath(path) {
    // capture the elevation of the first node, so we can calculate elevation diffs for each point
    // e.g. "+23 ft from starting point"
    var start_elevft = Math.round(path[0][2] * 3.28084); // from meters to feet

    // create a list of points from the points given
    // keep a cumulative sum of distance traveled (meters) over segments so far; used to calculate "miles" as the X axis location for this node
    var points  = [];
    var total_m = 0;
    for (var i=0, l=path.length; i<l; i++) {
        var lon    = path[i][0];
        var lat    = path[i][1];
        var elevft = Math.round( path[i][2] * 3.28084 ); // from meters to feet

        // increment the total meters traveled when the hiker arrives at this node: that is, the distance from this node to the previous node
        // then express that as miles for the current node  (at this node, you have come X miles)
        if (i) {
            var plon = path[i-1][0];
            var plat = path[i-1][1];
            var slen = Math.sqrt( ((lon-plon)*(lon-plon)) + ((lat-plat)*(lat-plat)) );
            total_m += slen;
        }
        var miles  = 0.000621371 * total_m;

        // tooltip content: elevation is +- X feet relative to the starting node
        var delev  = Math.abs(start_elevft-elevft);
            delev = (elevft>=start_elevft ? '+' : '-') + delev;
        var text   = "Elevation: " + elevft + " ft" + "<br/>" + delev + " ft compared to start";

        // ready, stick it on
        points.push({ lon:lon, lat:lat, elevft:elevft, miles:miles, text:text });
    }

    // done!
    return points;
}

Basically, it accepts the list of 3-tuples and returns back a flat list of objects, each one forming a point on the chart. Each point has elevft which is the elevation in feet and thus the Y value, has miles which is the distance traveled and thus the X value, and has text which will be used as a spiffy tooltip. The points also have lat and lon so a chart point can be correlated to a location on the map, which will be handy when we add mouseover events to the chart. (yeah, lon and lat are misnomers; it's in the map's spatialReference)

References:
http://sampleserver6.arcgisonline.com/arcgis/rest/services/Elevation/WorldElevations/MapServer/exts/ElevationsSOE_NET/ElevationLayers/0
https://developers.arcgis.com/en/javascript/jsapi/geoprocessor-amd.html

Charting it

When the Elevation geoprocessor returns, we simply grabbed the path and called it good. Well, replace that with something that massages the path using makeElevationProfileFromPath() and then charts it using renderElevationProfileChart()
    elevsvc.submitJob(params, function (reply) {
        // success: grab the 1 path we were given back, convert it into chart-friendly points, then chart them
        var path;
        try {
            path = reply.geometries[0].paths[0];
        } catch(e) { alert("Elevation service didn't return an elevation profile."); }

        // two steps here: convert the path to points, then hand the points off for charting to the DIV with id="elevationgraph"
        // general principle of separating into steps, so we can debug them or mess with them separately
        var points = makeElevationProfileFromPath(path);
        renderElevationProfileChart(points,'elevationgraph');
    }, function (status) {
        //console.log('status ping');
    }, function (error) {
        alert(error);
    });

The renderElevationProfileChart() function is mostly just Highcharts' own innate awesomeness. We preprocess the data one more step to grab the minimum, because an elevation chart is relatively flat when you live at 8,500 feet and are trying to show a difference of +200 feet. And we rename the fields to properly be x and y and name as Highcharts expects.

// given a set of chart-friendly points as returned from makeElevationProfileFromPath() plot it via Highcharts
// this is straightforward Highcharts charting, with the only interesting magic being the series.mouseOver effect
// as you mouse over the chart, the lat & lon are noted from the moused-over chart point, and HIGHLIGHT_MARKER moves on the map
function renderElevationProfileChart(points,containerid) {
    // massage it into the "x" and "y" structs expected by Highcharts: lon & lat are extraneous (used for mouseover), X and Y are the axis position and values, ...
    // also keep track of the lowest elevation found, acts as our 0 on the chart
    var lowest = 1000000;
    var data   = [];
    for (var i=0, l=points.length; i<l; i++) {
        data.push({ x:points[i].miles, y:points[i].elevft, name:points[i].text, lon:points[i].lon, lat:points[i].lat });
        if (points[i].elevft < lowest) lowest = points[i].elevft;
    }

    // render the given set of points from makeElevationProfileFromPath() into a Highcharts graph
    // the idea is that we want to reuse code between various types of linear features that may have elevation, so we don't hardcode element IDs into the lower-level functions, you see...
    var chart = new Highcharts.Chart({
        chart: {
            type: 'area',
            renderTo: containerid
        },
        title: {
            text: 'Elevation Profile'
        },
        xAxis: {
            title: {
                text: 'Distance (mi)'
            }
        },
        yAxis: {
            title: {
                text: 'Elevation (ft)'
            },
            min:lowest,
            allowDecimals:false
        },
        legend: {
            enabled:false
        },
        tooltip: {
            crosshairs: [true,true],
            formatter: function () {
                return this.point.name;
            }
        },
        plotOptions: {
            area: {
                marker: {
                    enabled: false,
                    symbol: 'circle',
                    radius: 2,
                    states: {
                        hover: {
                            enabled: true
                        }
                    }
                }
            },
            series: {
                point: {
                    events: {
                        mouseOver: function() {
                            var point = new esri.geometry.Point(this.lon,this.lat,MAP.spatialReference);
                            if (HIGHLIGHT_MARKER.graphics.length) {
                                HIGHLIGHT_MARKER.graphics[0].setGeometry(point);
                            } else {
                                var outline = new esri.symbol.SimpleLineSymbol(esri.symbol.SimpleLineSymbol.STYLE_SOLID, new dojo.Color([0,0,0]), 1);
                                var symbol  = new esri.symbol.SimpleMarkerSymbol(esri.symbol.SimpleMarkerSymbol.STYLE_CIRCLE,10,outline,new dojo.Color(HIGHLIGHT_COLOR2) );
                                HIGHLIGHT_MARKER.add(new esri.Graphic(point,symbol));
                            }
                        }
                    }
                },
                events: {
                    mouseOut: function() {
                        HIGHLIGHT_MARKER.clear();
                    }
                }
            }
        },
        series: [{ name: 'Elevation', data: data }]
    });
}
Again, most of this is just how awesome Highcharts is. A lot of this is just configuring the axes and the tooltips (see the name field for the tooltip?).  The cool mouse interactivity is based on three things:
  • If your X axis were evenly spaced, your usual "data" would be a simple list of Y values, and Highcharts would assume that each datum's X was X+1  But each point can be an object, as in this case, where x and y are explicitly given as attributes. This is how you achieve a chart with unevenly spaced X values.
  • These point-objects can have any attributes, as long as at least x and y are present. We add the name attribute, which is then used in the tooltip callback, and the lat and lon attributes which are used in a series.point.mouseOver callback.
  • Wow, Highcharts has a really slick series.point.mouseOver callback. Just thought I'd point that out again. This callback specifically either adds or else updates a marker in HIGHLIGHT_MARKER, which is a GraphicsLayer. (this wasn't in earlier examples, go see the live demo). Tip: The code could be slightly more efficient if the symbol were defined ahead of time, so we don't construct the same symbol repeatedly.
And there you have it. Awesome elevation profiles from ESRI's elevation service, and it was easy!
Thanks for reading. If you enjoyed the series, post a comment and share with your friends.


Question: I renamed the fields in renderElevationProfileChart() to be x and y and name. Why didn't I just name them that in makeElevationProfileFromPath() ? Because I assume that the client specs will change, that some day they'll want those points to be used for other to-be-determined analyses, so I prefer easy-to-read names such as elevft instead of y, especially two years down the road when x and y aren't entirely self-explanatory (cuz they're certainly not map coordinates). Just a style tip for you, cuz two years from now you'll look at your own code and wonder what in the world you were thinking... It happens to all of us. ;)

References:
http://www.highcharts.com/demo/
http://api.highcharts.com/highcharts



No comments:

Post a Comment