Saturday, January 17, 2015

CartoDB.JS - interactivity, and faking it

CartoDB is neat stuff. At work I don't get to use it as often as I would like, since we already have a PostGIS server. And their JavaScript library is pretty slick too: Leaflet, some Layer implementations, etc. It doesn't have quite everything built in but it does have plenty of tools to build.

In this case, what we had was some basic interactivity with a bunch of fields, a mouseover handler to show the polygon's project_name in a tooltip, and a click handler to open a popup with more info. But then, we needed to be able to trigger false clicks onto features based on their ID# so that it would act as if it had been really clicked, opening the informational popup and all.

Sadly, this  is impossible... but we can fake it!

Scenario

So, let me lay out a scenario for you, that starts off as typical CartoDB and then get a little weird.
  • We have a Leaflet map, and a CartoDB layer on it. The CartoDB layer has only Sublayer 0, and we use that to show polygons on the map. (it's filtered by search results using setSQL(), but that won't be relevant here)
  • The CartoDB layer has interactivity set using the project_name field, and has a mouseover effect to show a tooltip. It also has a click handler, which makes use of those same interactive fields. Again, pretty standard.
  • The interactivity is for rather a lot of fields beyond the project name. Although only the project_name is in the tooltip, we grab a lot more fields and also add a click handler to the interactivity. Thus, on a click we can create an informational popup without making a server hit and have an instant popup. (don't go overboard on lengthy field data being stuffed into those utfgrids, but it works fine for 7 fields at 5-20 letters apiece)
  • And then, now that the project is about to launch...
    We want an URL param that will trigger a click on one of the features. Given the cartodb_id of a project, we want to somehow find that feature on the map and then make a click event on it, so as to trigger that popup bubble.

The Basic Interactivity Code

For starters, the code for adding the layer to the L.Map and then adding interactivity, is pretty straightforward. The unusual part is that we ask for a lot of fields in the interactivity, and not the usual practice of asking for only the 1-2 that form your tooltip.

// CartoDB settings
var CARTODB_USERNAME  = "myaccount";
var CARTODB_TABLENAME = "projects";

// the L.Map and the CartoDB project overlay
var MAP, PROJVIZ;

// the CartoCSS colors
var CARTOCSS = "\
#projects {\
    line-width:1;\
    line-color:rgb(224,77,13);\
    polygon-fill:rgb(211,134,23);\
}";

// add the CartoDB overlay: the project areas
cartodb.createLayer(MAP, {
    user_name: CARTODB_USERNAME,
    type: 'cartodb',
    sublayers: [{
        sql: "SELECT * FROM projects",
        cartocss: CARTOCSS
    }]
}).addTo(MAP).done(function(layer) {
    // create a global reference for easy access
    PROJVIZ = layer;

    // your usual interactivity, but with an unusual number of fields
    // since we wasnt simple tooltips but also information-packed popups
    PROJVIZ.setInteraction(true).setInteractivity('cartodb_id,project_name,grantee,year,state,acres,dollarvalue,bboxs,bboxw,bboxn,bboxe');
    PROJVIZ.on('featureOver', function(event,latlng,pos,data) {
        document.getElementById('map').title = data.project_name;
    }).on('mouseout', function(event,latlng,pos,data) {
        document.getElementById('map').title = "";
    }).on('featureClick', function(event,latlng,pos,data) {
        var html = data.project_name;
        // then a bunch more fields: html += data.acres + data.grantee + ...
        MAP.openPopup(html,latlng);
    });
});
Point being, our interactivity asks for a lot of fields for popup purposes, and is added to the map, and works gorgeously. Basically copy-paste LeafletJS technique.





But I Don't Want To Click!

So then the request came in, that if we give an ID# in the query string, the map should automagically zoom to that feature and trigger that very same click. The cop-out would be that we do a SQL search and also create a different popup, ...

But then we would miss out on a lot of the other complexity that eventually crawled into that featureClick callback over time, such as highlighting stuff, fetching a chart based on the acres and dollarvalue, zooming the map to the given bbox. And duplicating that code would just be not great.

So, back up a bit and fix up that featureClick anonymous callback, to use a named function instead. You really should have done this in the first place, keeping your anonymous callbacks brief and not "visually polluting" that CartoDB "done" handler way back.
on('featureClick', function(event,latlng,pos,data) {
    openClickPopup(latlng,data);
});

function openClickPopup(latlng,data) {
    var html = data.project_name;
    // then a bunch more fields: html += data.acres + data.grantee + ...
    MAP.openPopup(html,latlng);
    // and about 20 lines of other feature-creep
}
Now we have a way in: the openClickPopup() accepts a latlng (where th anchor the popup) and some data (field attributes for a record in the projects table), and doesn't care where it came from. So now we really can do a SQL query and just grab *, then hand off not to a true featureClick event handler, but to the featureClick handler's real handler.


Bur wait... The latlng. The featureClick event has event.latlng for the click, butthe projects table doesn't have such a thing. Even if our SQL asks for the centroid, that's not solid: a L-shaped or U-shaped polygon would clearly have a centroid not even inside the shape. So hooray for ST_PointOnSurface()

// make a SQL query to get the same fields as we use in the interactivity (* is good)
// and also grab a lat and lng so we can fake a location
// given a latlng and a "data" of field attribs, we can use openClickPopup()
// ST_PointOnSurface() is an excellent alternative to ST_CENTROID()
var id = params.param('id');
var sql = new cartodb.SQL({ user: CARTODB_USERNAME });
sql.execute("SELECT *, ST_X(ST_POINTONSURFACE(the_geom)) AS lng, ST_Y(ST_POINTONSURFACE(the_geom)) AS lat FROM projects WHERE cartodb_id = {{id}}", { id:id }).done(function(records) {
    // compose the same params we would normally have, and hand them off
    var data   = data.records[0];
    var latlng = L.latLng([data.y,data.x]);

    // for extra verisimilitude, you can in fact trigger a click event on the CartoDB layer
    // this may make more sense than simply calling openClickPopup() for your specific need
    OVERLAYS['projects'].trigger('featureClick', null, latlng, null, data, 0);
    //openClickPopup(latlng,data);
}).error(function(errors) {
    alert(errors[0]);
});


So there we have it. In fact, it was nothing really special, as much as just pointing out the benefits of following some elementary practices and abstracting out your event handlers (featureClick) and their intended consequences (popups, highlights, automatic search, charts, ...).





No comments:

Post a Comment

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