Saturday, January 18, 2014

ArcGIS API, Highcharts, and kickbutt elevation profiles - 2. Click events, Query tasks, and Graphics layers

This posting expands upon my previous posting, in which I showed you how to make a very basic Hello World map using ArcGIS JavaScript API. This is part 2 of 4, leading up to very excellent interactive elevation profile charts. If you haven't read the previous post, at least read up on the ArcGIS JavaScript API

Adding Events


ArcGIS JS API is based on Dojo, and in Dojo you "connect" events to handlers using .connect() method. For example, to add a click handler to the map would go like this:
// the click event is a standard DOM event, but has some extra attributes too
// event.mapPoint is a esri.geometry.Point instance
// https://developers.arcgis.com/en/javascript/jsapi/point-amd.html
dojo.connect(MAP, "onClick", function (event) {
    console.log( event.mapPoint );
});
The documentation lists the various types of events available to each of the classes, e.g. a Graphicslayer can accept a click event, and that event has a .graphic attribute indicating the specific Graphic that was clicked.

Basic Geometries


In OpenLayers and ArcGIS API, a geometry is something separate from a marker or a map feature: it's a set of coordinates in space, which can be read or set separately from the vector feature ("Graphic", see below). If you're coming from Leaflet, imagine the separation of a LatLng from a Marker, and keep in mind that this separation applies to Polygon and Polyline features.

In ArcGIS JS API, a Geometry also has a spatialReference  This is different from OpenLayers in which all features are presumed to have the same SRS as the map. As such, the constructor has an additional argument, this being the spatialReference ID.
// tip: 4326 is WGS94 latlon, 102100 is web mercator
var srs = new SpatialReference({ wkid: 4326 });
var point = new esri.geometry.Point(-106.83, 39.19, srs );
var rect = new esri.geometry.Extent(-106.88, 39.16, -106.79, 39.22, srs );

// tip: the map has a .extent attribute, and this has a .getCenter() method
// the Extent is an Extent instance like the one above, and getCenter() returns a Point geometry
console.log( MAP.extent );
console.log( MAP.getCenter() );
Reference: https://developers.arcgis.com/en/javascript/jsapi/geometry-amd.html

Adding Graphics


A Graphic is a vector feature, which has a geometry/location, attributes, and a symbolization. In this sense it's quite similar to OpenLayers.Feature.Vector. Leaflet's Path subclasses (Circle, Marker) are somewhat different as I described above, is that Leaflet tends to tightly integrate the geometry and the feature (again, think Marker vs LatLng).

In OpenLayers, a Graphic is not added directly to the Map but to a vector layer, and that layer is added to the map. ArcGIS JS API is similar: you add a esri.layers.GraphicsLayer to the map, then add a esri.Graphic to the layer. (Leaflet is unusual that a Path instance is treated as a Layer all on its own; think about a LayerGroup, and you're thinking on the same track as these "vector layers")
var DOT_COLOR  = [255,  0,  0];
var DOT_BORDER = [  0,  0,  0];


var DOTS = new esri.layers.GraphicsLayer();
MAP.addLayer(DOTS);

var point   = MAP.extent.getCenter();
var outline = new esri.symbol.SimpleLineSymbol(esri.symbol.SimpleLineSymbol.STYLE_SOLID, new dojo.Color(DOT_BORDER), 1);
var dotsym = new esri.symbol.SimpleMarkerSymbol(esri.symbol.SimpleMarkerSymbol.STYLE_CIRCLE,10,outline,new dojo.Color(DOT_COLOR) );
DOTS.add(new esri.Graphic(point,dotsym));
As you can see, this defines a "dotsym" symbol, and defines a point (the center of the map), then connects the two into a Graphic, then adds that Graphic to a previously-empty GraphicsLayer.

Exercise: Events + Graphics = Dots!


A quick demonstration to tie those two concepts together. When you click the map, the event's .mapPoint attribute supplies the geometry/location of the click. Combine this with a GraphicsLayer and you can litter your map with dots just by clicking.

HTML

<!DOCTYPE HTML>
<html>
<head>
    <!-- ArcGIS JS API and related CSS -->
    <link rel="stylesheet" href="http://js.arcgis.com/3.8/js/dojo/dijit/themes/claro/claro.css">
    <link rel="stylesheet" type="text/css" href="http://js.arcgis.com/3.8/js/esri/css/esri.css">
    <script src="//js.arcgis.com/3.8/"></script>

    <style type="text/css">
    #map {
        width:5in;
        height:5in;
        border:1px solid black;
    }
    </style>
</head>
<body class="claro">

    <div id="map"></div>

</div>

</body>
</html>
JavaScript
var MAP; // the esri.Map object
var OVERLAY_TRAILS; // an ArcGIS Dynamic Service Layer showing the trails
var HIGHLIGHT_MARKER; // Graphics layer showing a marker over the selected highlighted trail, e.g. on cursor movement over the elevation chart

var START_W = -106.88042;
var START_E = -106.79802;
var START_S =   39.16306;
var START_N =   39.22692;

var ARCGIS_URL = "http://205.170.51.182/arcgis/rest/services/PitkinBase/Trails/MapServer";
var LAYERID_TRAILS = 0;

var HIGHLIGHT_COLOR2 = [255,  0,  0]; // never mind the weird name, it'll make sense in a few more postings

require([
    "esri/map",
    "dojo/domReady!"
], function() {
    // the basic map
    MAP = new esri.Map("map", {
        extent: new esri.geometry.Extent({xmin:START_W,ymin:START_S,xmax:START_E,ymax:START_N,spatialReference:{wkid:4326}}),
        basemap: "streets"
    });

    // add the trails overlay to the map
    OVERLAY_TRAILS = new esri.layers.ArcGISDynamicMapServiceLayer(ARCGIS_URL);
    OVERLAY_TRAILS.setVisibleLayers([ LAYERID_TRAILS ]);
    MAP.addLayer(OVERLAY_TRAILS);

    // add an empty Graphics layer
    HIGHLIGHT_MARKER = new esri.layers.GraphicsLayer();
    MAP.addLayer(HIGHLIGHT_MARKER);

    // on a map click add a dot to the Graphics layer
    dojo.connect(MAP, "onClick", function (event) {
        // the clear() method removes all graphics from this Graphicslayer, so there'd only be 1 dot at a time
        //HIGHLIGHT_MARKER.clear();

        var point   = event.mapPoint;
        var outline = new esri.symbol.SimpleLineSymbol(esri.symbol.SimpleLineSymbol.STYLE_SOLID, new dojo.Color([0,0,0]), 1);
        var dot     = new esri.symbol.SimpleMarkerSymbol(esri.symbol.SimpleMarkerSymbol.STYLE_CIRCLE,10,outline,new dojo.Color(HIGHLIGHT_COLOR2) );
        var graphic = new esri.Graphic(point,dot);
        HIGHLIGHT_MARKER.add(graphic);
    });
}); // end of setup and map init

And there you go, a basic exercise in events, symbols, and Graphics.

References:
https://developers.arcgis.com/en/javascript/jsapi/map-amd.html#event-click
https://developers.arcgis.com/en/javascript/jsapi/point-amd.html
https://developers.arcgis.com/en/javascript/jsapi/graphicslayer-amd.html
https://developers.arcgis.com/en/javascript/jsapi/graphic-amd.html
https://developers.arcgis.com/en/javascript/jsapi/symbol-amd.html


Query Task


The Query task is used to make a query and find features on an ArcGIS REST endpoint. The most common use case (perhaps) is of identifying what is underneath the mouse click, but this can be done separately of any map, just to make a query.
// query one specific layer,  getting the NAME field for any trail where closed=0
// the success callback simply says how many results were found
var queryTask = new esri.tasks.QueryTask(ARCGIS_URL + LAYERID_TRAILS  );
var query     = new esri.tasks.Query();
query.returnGeometry = false;
query.outFields      = ['Name'];
query.where          = "Closed=0";

dojo.connect(queryTask, "onComplete", function(featureSet) {
    var howmany = featureSet.features.length;
    alert('found ' + howmany + ' results'):
});
queryTask.execute(query);
A featureSet result has a .features attribute, and this is a list of Graphic objects. Yes, the returned features aren't just geometries, but geometry-and-attributes already wrapped into a Graphic object, and almost ready to draw onto the map. The only thing missing is to define a symbol for the features (I mean, the Graphics) and you're ready to rock. More on that below.

Notes:
  • The returnGeometry flag to the Query determines whether the geometry is sent. If you only need attribute data (e.g. a list of trails) then set this to false and your queries will be much faster but your returned Graphics have a null geometry. If you don't intend to draw the returned vector features onto the map, definitely leave out the geometry.
  • There's a bug in 10.1 where queries will fail, if you submit the same query multiple times in the same session (reloading the page makes it work again). I like to add a random factor to my .where clause, as I'll show below.
  • You can do spatial queries such as "is contained within _____" by adding a .geometry attribute to your Query. This will also be illustrated below.

This example is a little more complex. It makes a Query, then plots the resulting Graphics onto the map, as well an pestering you with alerts as to what it found.
var MAP;
var OVERLAY_TRAILS;
var HIGHLIGHT_TRAIL;

var START_W = -106.88042;
var START_E = -106.79802;
var START_S =   39.16306;
var START_N =   39.22692;

var ARCGIS_URL = "http://205.170.51.182/arcgis/rest/services/PitkinBase/Trails/MapServer";
var LAYERID_TRAILS = 0;

esri.config.defaults.io.proxyUrl = "proxy.php";

require([
    "esri/map",
    "dojo/domReady!"
], function() {
    // the basic map
    MAP = new esri.Map("map", {
        extent: new esri.geometry.Extent({xmin:START_W,ymin:START_S,xmax:START_E,ymax:START_N,spatialReference:{wkid:4326}}),
        basemap: "streets"
    });

    // add the trails overlay to the map
    OVERLAY_TRAILS = new esri.layers.ArcGISDynamicMapServiceLayer(ARCGIS_URL);
    OVERLAY_TRAILS.setVisibleLayers([ LAYERID_TRAILS ]);
    MAP.addLayer(OVERLAY_TRAILS);

    // we'll want to highlight the trail, and to draw a marker linked to the chart; define those 2 graphics layers here
    // why not 1 layer with both graphics? easier to untangle this way, compraed to iterating over the features and finding which is the marker or line
    HIGHLIGHT_TRAIL  = new esri.layers.GraphicsLayer({ opacity:0.50 });
    MAP.addLayer(HIGHLIGHT_TRAIL);

    // on a map click, make a query for the trail and then for its elevation profile...
    dojo.connect(MAP, "onClick", function (event) {
        handleMapClick(event);
    });

    // now run a query for all non-closed trails,
    // rendering them to the map and annoying the user with alerts for every result
    var queryTask = new esri.tasks.QueryTask(ARCGIS_URL + LAYERID_TRAILS  );
    var query     = new esri.tasks.Query();
    query.returnGeometry = false;
    query.outFields      = ['Name'];
    query.where          = "Closed=0";

    var symbol = new esri.symbol.SimpleLineSymbol(esri.symbol.SimpleLineSymbol.STYLE_SOLID, new dojo.Color([255,255,  0]), 5);
    feature.setSymbol(symbol);

    // the success handler: iterate over the features (Graphics)
    // add them to the map, and also illustrate the use of the Graphics' .attributes property
    dojo.connect(queryTask, "onComplete", function(featureSet) {
        if (! featureSet.features.length) return alert('Nothing matched your search.');
        for (var i=0, l=featureSet.features.length; i<l; i++) {
            var feature = featureSet.features[i];
            alert(feature.attributes.Name);
            HIGHLIGHT_TRAIL.add(feature);
        }
    });
    queryTask.execute(query);
});
This example illustrates quite a lot of little details:
  • Basic map initialization, plus a GraphicsLayer
  • Performing a Query for records matching some criteria
  • Plotting the returned Graphics to the map and accessing their .attributes attribute


With a little imagination, you can see how to make use of Queries even if there's not a map involved. A returnGeometry=false Query for all Closed=0 trails could be used to populate a selection list or a list of all trails, fetched dynamically from the server. That's pretty cool, in that you don't need to maintain a "HTML copy" of which trails should be listed; fetch 'em from the server.

A quick mention of the Identify tool: ArcGIS JS API also has a IdentifyTask which functions quite similarly to the Query tool. However, it is based on the concept that you have clicked the map (much as a WMS GetFeatureInfo) and have a click position, the map's width and height, etc. For this reason, I often use a Query instead of a Identify, even if I'm clicking the map. The next section will show this in detail.

References:

http://gis.stackexchange.com/questions/52612/arcgis-server-10-1-inconsistent-querying-errors
http://forums.arcgis.com/threads/72894-Etags-and-Intermittent-QueryTask-Server-Errors


Bring It Together: Click To Query (not to Identify)


Our goal is to make kickbutt elevation profiles. In our case, the user would click the map and that would trigger a Query; the Query result would then be drawn onto the map, its attributes would be displayed into a table or HTML DIV, and so on.

I mentioned earlier the Identify tool, which is specifically designed to accept a map click and look up info at that location. But I prefer to use the Query tool, because it allows for more nuanced filtering (e.g. where Closed=0) and for more consistent program code: when the client wants to query a trail from a listing and not by a map click at all, your other Query would be very similar, and thus easier to debug or to keep in sync if you need to make changes to both "query modes".

And, if you use a Query you're still not missing out on location: a Query can accept a .geometry parameter to filter by location, and that location can even be fabricated without using a map click (e.g. if I were at this lat & lon, and wanted a circle of 5 miles, ...). The Identify tool, by contrast, uses the map's width and height and the screen location of the click, so you're really stuck with clicking, and the click must be in the viewport, and you miss out on killer stuff like "within a 5 mile circle of...".

Here's a simple click-to-Query demonstration. A click event has the Geometry.Point extracted and hands off to a generic "find what's at this location" handler. The neat part is that (unlike Identify) findWhatsHere() could be handed any arbitrary point.
// add to the MAP a click event handler
// it extracts the point that was clicked (a Geometry.Point, not a screen pixel)
// and hands off to a "query maker" for that location
// the beauty, is that findWhatsHere() could be fed any point location
dojo.connect(MAP, "onClick", function (event) {
    findWhatsHere(event.mapPoint);
});

// given an arbitrary point in the map's spatialReference (web mercator, from a click)
// buffer it out by 50 meters and make a Query for whatever's inside that square
function findWhatsHere(event) {
    // compose the query: just the name field, a 50 meter "square radius" from our click
    // how do we know it's meters? cuz the map's default spatialReference is web mercator which is meters
    var query = new esri.tasks.Query();
    query.returnGeometry = true;
    query.outFields      = [ "NAME" ];    query.where          = "Closed=0";    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) {
        handleSearchResults(featureSet);
    });
}
With a little imagination you can make some improvements on this, and that's your homework should you choose to accept it:
  • findWhatsHere() could accept a radius parameter, and use that instead of the hardcoded 50. In the onClick handler you'd hardcode a 50, and the other hypothetical portion of code which would call findWhatsHere() could look up the radius from a select box, a saved personal personal preference, etc.
  • Or you could simply use map.extent and use the map's current viewport. If I were to do that, I wouldn't tie it to a Map's onClick event, but maybe to its pan-end and move-end events. Then, whenever the map is panned or zoomed, a new Query could be run. Then you'd get new results as you pan the map, kinda like Google Maps updates your results as you pan the map.
  • The query uses a rectangle centered on the location, and not truly a circle. As such it would return results that are close to the circle but technically outside it. If you're particular, instead you'd create a esri.geometry.Circle
References:
https://developers.arcgis.com/en/javascript/jsapi/query-amd.html
https://developers.arcgis.com/en/javascript/jsapi/circle-amd.html



Proxy Servers

A quick note before we move on.

 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.


So, this concludes #2 in the series. I hope you've enjoyed it.
Next up will be more advanced topics such as geoprocessing, and the elevation service.


No comments:

Post a Comment

Note: Only a member of this blog may post a comment.