Wednesday, January 22, 2014

ArcGIS API, Highcharts, and kickbutt elevation profiles - 3. Geoprocessing, Proxies, and the Elevation service

This posting is #3 in a series, using the ArcGIS JavaScript API to start with a very basic Hello World map, through some education on events, Graphics, and so on, and leading up to very excellent interactive elevation profile charts. If you haven't read the previous posts, at least read up on the ArcGIS JavaScript API

Proxy Servers

A quick note before we get started. You probably need to use a proxy program, to get around the browsers' same-origin restriction.

 Browsers refuse to accept data from a server (e.g. your geoprocessor) unless the URL is the same as your website. The ArcGIS JS API has workarounds in place so you don't need a proxy for very small requests (e.g. a Query with only 1 where clause and a very simple geometry) but a proxy is required for anything larger (e.g. when you create a long clause and/or more complex geometries).

You really should set up a proxy service:
  • Step 1: Install a proxy program. You can write your own (I have a previous blog post on that topic) or download one of ESRI's on their page about proxies.
  • Step 2: Define the proxyUrl like this:   esri.config.defaults.io.proxyUrl = "proxy.php";
Once you do this, requests that are too large will automagically be routed through your proxy. And if you set up your proxy correctly, the service calls will still work.

Geoprocessing Services

In the last posting, I showed how to use a Query to request info from an ArcGIS server. There are other services available via the API such as geocoders and directions services. I'll suffice with a simple example, as these services are well documented and you should be able to adapt the previous examples to ESRI's documentation and be off to a good start.
// a hypothetical GraphicsLayer, which may have polygons drawn onto it
// if you read the previous post, you know all about GraphicsLayer and Graphics  ;)
function findPolygonAcres() {
    polygons = DRAWN_AREAS.graphics;
    if (! polygons.length) return alert("No polygons showing.");
    unionAndArea(polygons);
}

// step 1: find the union of all the submitted polygons, so we don't double-count acres if two polygons overlap
// the service accepts a list of Geometry objects, returns a single Geometry which is the union of the ones you gave it
// we're passing in Graphics, and need to extract their Geometries
function unionAndArea(graphics) {
    var geoms = [];
    for (var i=0, l=graphics.length; i<l; i++) geoms.push( graphics[i].geometry );

    var geomsvc = new esri.tasks.GeometryService("http://sampleserver3.arcgisonline.com/ArcGIS/rest/services/Geometry/GeometryServer");
    geomsvc.union(geoms, function(singlegeom) {
        getPolygonArea(singlegeom);
    });
}

// step 2: given a single Geometry, ask the GeometryService to calculate the area
// note that the service will accept a list of polygons, but we did want to find the union
// so we don't double count AND I wanted to be flashy about cascading a geoprocessing result to another geoprocessor
function getPolygonArea(polygon) {
    var params             = new esri.tasks.AreasAndLengthsParameters();
    params.lengthUnit      = esri.tasks.GeometryService.UNIT_FOOT;
    params.areaUnit        = esri.tasks.GeometryService.UNIT_ACRES;
    params.calculationType = 'geodesic';
    params.polygons        = [ polygon ];

    // since we did a union, we know there's only 1 result, and can simply take item 0 from the returned list
    var geomsvc = new esri.tasks.GeometryService("http://sampleserver3.arcgisonline.com/ArcGIS/rest/services/Geometry/GeometryServer");
    geomsvc.areasAndLengths(params,function (areas_and_lengths) {
        var acres = areas_and_lengths.areas[0];
        alert('acres + ' acres');
    },function (error) {
        alert(error.details);
    });
}
Every GeometryService method has its own parameters and returns, and you'll want to spend some time with the documentation for whatever services you'll need in your application. Don't memorize the docs, as much as learn what services exist and learn the basic "input and callback" design pattern.

References:
https://developers.arcgis.com/en/javascript/jsapi/geometryservice-amd.html
https://developers.arcgis.com/en/javascript/jsapi/geometryservice-amd.html#union
https://developers.arcgis.com/en/javascript/jsapi/geometryservice-amd.html#areasandlengths


The Geoprocessor Task

The Geoprocessor task is to access some arbitrary geoprocessing endpoint. Typically these will be some custom geoprocessing service, to perform some calculation that ESRI's own services don't provide. Every custom geoprocessor will be different, by nature. For example, a geoprocessor could accept as parameters a point location and a dollar value, and return a list of Graphics which are houses within 10 miles within the price range. Or a geoprocessor could accept a list of polygon geometries, take the union internally and compare against the Census/ACS data, and return a structure of demographic attributes within that polygon (not necessarily Graphics at all).

Geoprocessors do have a .execute() method which hypothetically will submit the parameters and get back results in one call. But in reality I've always had to use the .submitJob() design pattern. It's slightly more complex, but for the services I've used it's more reliable than .execute().
// the generic design pattern for Geoprocessor, using submitJob
// this hypothetical geoprocessing service accepts a single Point geometry and a dollar amount
// and returns a list of all houses fitting the price range (a list of Graphics which could go onto the map)
var params = {};
params.dollars = document.getElementById('pricerange').value;
params.location = MAP.extent.getCenter();

var housefinder = new esri.tasks.Geoprocessor(SERVICE_URL);
housefinder.submitJob(params, function (results) {
    // param 2: success callback, with the parameter being the results structure
    // the structure depends on the service, and every service is different
    // this one says how many results came up, the largest square footage found, and a list of Graphics
    alert('Found ' + results.numresults + ' houses.' + "\n" 'Largest was ' + results.maxsqft + ' sq ft');
    for (var i=0, l=results.houses.length; i<l; i++) {
        GRAPHICS.add( results.houses[i] );
        document.getElementById('listing').innerHTML += results.houses[i].attributes.title;
    }
}, function (status) {
    // param 3: status callback; every few seconds the API will ping the service again to ask the status of the job
    // your service may be super spiffy and have useful messages such as "23 out of 55 processed" or "62% done"
    // and maybe your application would like to display that status in a popup
    console.log(status.message);
}, function (error) {
    // param 4: error callback
    alert(error.message);
});
 
References:
https://developers.arcgis.com/en/javascript/jsapi/geoprocessor-amd.html
http://sampleserver6.arcgisonline.com/arcgis/rest/services/Elevation/WorldElevations/MapServer/exts/ElevationsSOE_NET/ElevationLayers/0

The Elevation Geoprocessor




In our case, we're interested in ESRI's elevation geoprocessing service. It took a little fussing to figure out, but the inputs and outputs are:
  • Only one input param: geometries a list of esri.Geometry.Polyline objects
  • Output: an object with a .geometries attribute, one for each of your input Polylines; each geometry has a .paths attribute, a list of which corresponds to the paths in each Polyline; each path is a list of 3-tuples, each one being [ x, y, elevation ]  X and Y are in whatever coordinates you gave (usually the map's spatialReference) and Elevation is in meters.
In other words: You pass in a list of Polylines, it returns a list of lines and paths, but with Z information.
Question: When we query the trails, why not just have the trails server hand back the Z as part of the Query? We're already using .returnGeometry=true right? Answer: The Query made to the server will omit the &returnZ=true parameter if you include it. The ArcGIS JS API doesn't handle 3D data, all the way down to the esri.Geometry.Point, so even if you could modify the request over the wire, you'd get back data that the rest of the API can't handle. No, elevation data is a truly separate thing.
In our case we'll pass in exactly 1 line (the trail that's highlighted) and get back 1 geometry with 1 path, and there we go. And here it is. This builds on my previous code bites, where we created the MAP and the HIGHLIGHT_TRAIL GraphicsLayer.
// on a map click, make a query for the trail and then for its elevation profile...
dojo.connect(MAP, "onClick", function (event) {
    handleMapClick(event);
});

// handle a map click, by firing a Query
function handleMapClick(event) {
    // if the trails layer isn't in range, skip this
    if (! OVERLAY_TRAILS.visibleAtMapScale ) return;

    // compose the query: just the name field, and in this 50 meter "radius" from our click
    var query = new esri.tasks.Query();
    query.returnGeometry = true;
    query.outFields      = [ "NAME" ];
    query.geometry       = new esri.geometry.Extent({
        "xmin": event.mapPoint.x - 50,
        "ymin": event.mapPoint.y - 50,
        "xmax": event.mapPoint.x + 50,
        "ymax": event.mapPoint.y + 50,
        "spatialReference": event.mapPoint.spatialReference
    });

    var task = new esri.tasks.QueryTask(ARCGIS_URL + '/' + LAYERID_TRAILS );
    task.execute(query, function (featureSet) {
        handleMapClickResults(featureSet);
    });
}

// handle the Query result
function handleMapClickResults(features) {
    // start by clearing previous results
    HIGHLIGHT_TRAIL.clear();

    // grab the first hit; nothing found? bail
    if (! features.features.length) return;
    var feature = features.features[0];

    // highlight using the given vector geometry...
    var symbol = new esri.symbol.SimpleLineSymbol(esri.symbol.SimpleLineSymbol.STYLE_SOLID, new dojo.Color(HIGHLIGHT_COLOR), 5);
    HIGHLIGHT_TRAIL.add(feature);
    feature.setSymbol(symbol);

    // now make the geoprocessing call to fetch the elevation info
    // there's only 1 param: a list of geometries; in our case the list is 1 item, that being the feature we got as a result
    var elevsvc = new esri.tasks.Geoprocessor("http://sampleserver6.arcgisonline.com/arcgis/rest/services/Elevation/WorldElevations/MapServer/exts/ElevationsSOE_NET/ElevationLayers/0/GetElevations");
    var params = { geometries:[ feature.geometry ] };
    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."); }

        // we now have a valid path, and want to massage it into chart-friendly format
        // more on that next time!
        console.log(path);
    }, function (status) {
    }, function (error) {
        alert(error);
    });
}
We're almost there! Clicking the map triggers a Query to find a trail under the click. The Query callback draws the returned Graphic onto the map (highlighting the trail) and then submits a geoprocessing request to the Elevation service. On a successful return, we have a single "path" which is a list of [x,y,z] tuples... and that's our elevation profile.

In my next posting, we'll massage the returned tuples into a nice chart-friendly structure, including miles traveled and elevation at each point, then chart is using my favorite chart system, Highcharts.

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

No comments:

Post a Comment