Monday, October 30, 2017

Mapbox Vector Tiles + Leaflet: Bugs and Workarounds

A few weeks ago, I posted about my first experiences with Mapbox's vector tile service and the Leaflet adapter for it. A lot of things went well and the end result was quite nice, and I covered a lot of how-to items for what worked well.

But a couple of items were buggy, or needed workarounds.

Published Changes Not Saving


I mentioned this previously, but continue to replicate it. I make changes to my Style in Mapbox Studio, hit Publish, then wait patiently for the changes to show up in the browser. Sometimes it works almost instantly, sometimes I have to wait 10 minutes, and sometimes it just doesn't happen.

I hit the /styles/v1/ endpoint in my debugger, and see that they really are serving up the old colors, are not yet including my new layer, are still showing the old name for the layer, and so on.

Workaround: go into Mapbox Studio again, make some change and then undo it, and hit Publish again. Sometimes a third time is required.

Clicking and mouseover effects


The queryRenderedFeatures() method accepts a Mapbox GL Point as the query coordinates, not a L.LatLng since it's not Leaflet. The programming to convert a L.Map click event into a Mapbox GL Point and then perform a query, is as follows:
MAP.on('click', function (e) {
    const canvas   = MBOVERLAY._glMap.getCanvasContainer();
    const rect     = canvas.getBoundingClientRect();
    const glpoint  = new mapboxgl.Point(e.originalEvent.clientX - rect.left - canvas.clientLeft, e.originalEvent.clientY - rect.top - canvas.clientTop);
    const features = MBOVERLAY._glMap.queryRenderedFeatures(glpoint, { layers: INTERACTION_LAYERS });
    // now go do something with the resulting "features" list
});

Maps becoming desynchronized on a zoom change


When the L.Map changes zoom level, e.g. any time we call a fitBounds() or setView() the Mapbox GL layer would fall out of sync. The vector layer would repaint, but often not at the same center, so would be offset from the rest of the map. This would happen pretty reliably too, with only moments of testing required.

The workaround here was a bit of a hack: any time the map becomes idle after changing, have it call setCenter() on its own center after a brief timeout. This seems to trigger a repaint of the Mapbox GL layer, bringing it into sync with the rest of the map.
MAP.on('moveend', function () {
    if (! MAP.overlay._glMap) return; // map's not ready yet e.g. startup
    const center = MAP.getCenter();
    setTimeout(function () {
        MAP.overlay._glMap.setCenter(center);
    }, 250);
});
Again, an ugly hack but a lot less ugly than what it solves!

Race conditions on startup


Outside of the map is a list of hydrology regions and clicking one will trigger some behaviors on the map. Some of these behaviors include changing some filters on the Mapbox GL layer. Problem is, the Mapbox GL layer can take several seconds to initialize. If someone clicks during this time, all sorts of funky hell breaks loose in the console because MBOVERLAY._glMap is not yet defined, the Style has not yet loaded, and so on.

The workaround is not surprising: GL Map's load event, which fires after the map has first loaded and completed its first painting of the layer. Now you know that MBOVERLAY._glMap is defined and ready!
MAP.overlay = L.mapboxGL({
    accessToken: OVERLAY_ACCESSTOKEN,
    style: OVERLAY_MAPBOXURL,
    pane: 'overlayPane',
}).addTo(MAP); 

MBOVERLAY._glMap.on('load', function () {
    // addSource() and addLayer()
    // and additional UI event handlers for folks who won't wait before clicking
});

What Went Well?


Most things worked well! Those were the focus of the previous articles. :)

Hooray Mapbox, for a really excellent product which allows for filtering and re-styling in ways that would traditionally have required a WMS server.

Tuesday, October 24, 2017

Leaflet-Mapbox-GL HOWTO: Highlighting an area

My series on Mapbox GL API combined with Leaflet using leaflet-mapbox-gl.js continues.

We have been working within a function called selectRegionByName() which would switch between choropleth view and borders-only view by way of filters. Last week we added waterways and river gauges to it as well, again using filters.

This week's icing on the cake: we want to highlight the selected region as well. The black borders are showing and the choropleth is suppressed, but can we give this area a good thick orange border to make it pop?


The Easy Way: Filtering a Dedicated Layer


No surprise here: the easiest mechanism would be to add a new layer to your Style, and filter it to match the region. If we stack the highlight layer above the black outline layer and give the stroke an opacity of 1, this would effectively hide the black border of this region in favor of the orange border.

  • Go into Mapbox Studio and add a new layer called regionhighlight.
    Use the hydrology regions as the Tileset.
    Select a line type rendering, give it a good thick stroke and make it orange.
  • Set a filter on the layer to match `region == Nonexistent` so that it's not in fact matching anything at all.
  • In client-side code, follow what we did the last few postings to add a new filter for regionhighlight matching the selected region name.
The resulting additions to selectRegionByName() would look a lot like this:

// highlight overlay on: filter to match this one region
MBOVERLAY._glMap.setFilter("highlightregion", ["==", 'name', name]);

// highlight overlay off: filter to match no region ever
MBOVERLAY._glMap.setFilter("highlightregion", ["==", 'name', "Nonexistent"]);

The Harder Way: Creating a New Layer Client-Side


Back when I implemented the highlighting, I hadn't quite wrapped my head around filtering and hadn't come up with the easy method above. Instead, I used a technique described in Mapbox's documentation which uses a Tileset as a data source and adds virtual layers to the client-side map.

First off, define a Source connected to your Tileset. You will need the Tileset's Mapbox URL and the name of it as it appears in your Tilesets page.
// define a new Source
// url is the Mapbox URL of the regions Tileset
MBOVERLAY._glMap.addSource('regions', {
    "type": "vector",
    "url": "mapbox://yourusername.abcdef1234",
});

// define a line layer using the above Source
// source is the Source name you defined above
// source-layer is the Mapbox-mangled name of that Tileset
MBOVERLAY._glMap.addLayer({
    "id": "highlightregion",
    "source": "regions",
    "source-layer": "HydroRegions-abcd1234",
    "type": "line",
    "paint": {
        "line-width": 4,
        "line-color": "orange",
    },
    "filter": ["==", 'name', "Nonexistent"],
});
Now, to activate that virtual layer, you would apply a filter:
// highlight overlay on
// filter to match this one region
MBOVERLAY._glMap.setFilter("highlightregion", ["==", 'name', name]);

// highlight overlay off
// filter to match no region ever
MBOVERLAY._glMap.setFilter("highlightregion", ["==", 'name', "Nonexistent"]);
This technique is a lot more complicated, and since you need to use filtering anyway in order to operate it, the first technique I described (a dedicated highlighting layer) gets the job done with less work. But if you are using someone else's Style and editing it is not an option, this technique would allow you to accomplish much the same effect.


Stroke vs Fill

One last note about highlighting: stroke versus fill. Mapbox layers are either polygons (a fill, with no stroke) or else lines (a boundary but no fill). If you want to highlight with both, you will need to define two layers, then set the filtering on both of them.

This goes for both doing highlights within the Mapbox Studio Style, or using the addLayer() technique.


Thursday, October 19, 2017

Leaflet-Mapbox-GL HOWTO: Filtering features on the map

My series on Mapbox GL API combined with Leaflet using leaflet-mapbox-gl.js continues.

Previously we created a function selectRegionByName() which would switch between a choropleth view and a plain-outline view, and zoom the map to a given region. Let's expand on that:

  • When we zoom in to a region, we toggle between choropleth and simple outline, and also zoom to the given area.
  • Now we also want to show the waterways and the gauges within that region, but not in other regions so as to minimize the visual noise.

Filtering Is Easy

Quite simply, this is done with setFilter() which you've already seen. In our case, the gauge points and waterway lines had already been tagged with a region attribute so filtering is quite easy. You should be able to patch these into selectRegionByName() with no difficulty.
// region selected// filter gauges and waterways layers, to match this regionMBOVERLAY._glMap.setFilter('gauges', ["==", 'region', name]);MBOVERLAY._glMap.setFilter('waterways', ["==", 'region', name]);

// null selected// filter gauges and waterways layers, to match no region at allMBOVERLAY._glMap.setFilter('gauges', ["==", 'region', 'Nonexistent']);MBOVERLAY._glMap.setFilter('waterways', ["==", 'region', 'Nonexistent']);

There's really not a lot to say about it. When an area is selected, set the filters to match only features in that region. When no area is selected, set an impossible filter.

New Filters Replace Old Filters

The real caveat here is that these filters replace your own filtering in the Style. If the gauge locations were already filtered in your Style, to show only those with "Status = Operational" then guess what? You just blew away that filter in favor of this new one: it's showing all gauges in the region regardless of their Status field.

This can mean some duplication of effort, adding the filter in your Style via Studio, then remembering to replicate that filtering here in the client side code in addition to the new region filter. An alternative, would be to use getFilter() to fetch the current set of filters, then add/remove the ones relevant to the region.

getFilter() returns the list of the filters currently applied to the layer. MBOVERLAY._glMap.getFilter("gauges") would return something like this, indicating a filter that matches ALL of the following filters, a filter clause by region and a filter clause by status.
[ "all", ['==', 'region', 'Northeastern Watershed'], ['==', 'Status', 'Operational'] ]
What we would want here, is to remove the region filter and leave all the rest, including that all at the start and any other clauses that aren't for region. And here we are:
// add a region filter// to whatever other filters were in placevar filters = MBOVERLAY._glMap.getFilter("gauges");filters.push([ '==', 'region', name ]);MBOVERLAY._glMap.setFilter('gauges', newfilters);

// clear the region filter// but re-apply any other filters previously in placevar filters = MBOVERLAY._glMap.getFilter("gauges");filters = filters.filter( (f) => { return f[1] != 'region' });MBOVERLAY._glMap.setFilter('gauges', filters);
This technique is a bit more work to set up, compared to the first example which is copy-paste in 20 seconds. But if you are already using filtering on your layers, this technique would preserve the filters already defined in the Style while also allowing you to define them client-side.

Of course, it's all dependent on your use case. If your filtering needs are fairly complex, or you're creating a whole filtering UI anyway, maybe you'd do better to define all your filters client side and keep them all in one place. 


Monday, October 16, 2017

Leaflet-Mapbox-GL HOWTO: Toggle between choropleth and boundaries

My series on Mapbox GL API combined with Leaflet using leaflet-mapbox-gl.js continues.

One of the first interesting tasks beyond just seeing the vector tiles on screen, would be to select one of those hydrology regions, then focus the map on it. When no area is selected, a statewide choropleth should indicate some statistical information about each region.
  • One of the datasets (I mean Tilesets) loaded into Mapbox is of the statewide hydrology regions. They each have a statistical field indicating their percentage completeness, and we will want a choropleth representation of it with some known breakpoints.
  • When an area is selected, we want to hide the choropleth and zoom in to that area, and switch over to a second rendition of those polygons: simple thick lines. Thus the choropleth color won't be distracting now that we're looking at a detailed view.
  • An area could be de-selected entirely, at which time we should switch back to choropleth, zoom back to statewide extent, and hide the lines.
  • The bounding box extents for each area, are present here in the client-side code: region name, west, south, north, east. So no special tricks are required there.

Adding Region Polygons to the Style


  • First step of course, was to upload the Tileset into Mapbox. Done.
  • Second, style up the two versions: a plain thick black outline when zoomed in, and a choropleth when zoomed out.
  • Solid black is pretty simple. Just click the layer name, fill in a color. Done.
  • Choropleth is more tedious but not too tough: Select the layer, and for the color hit the expander and select Enable Property Function and select a field. In my case I want to filter by value and I have category breaks already defined: I select # as the data type and Categorical as the classification. I then used "Add Stop" and entered the max values for each break, one by one.
And there we have it, a choropleth based on the completion percentage and a plain black outline. Both are visible in Mapbox Studio this whole time, but on the client side we're going to change that.


Toggling by Region Name


I wrote a function which accepts a region name, to zoom to it and highlight it and all. It also accepts null in order to zoom to the statewide view, selecting no area at all and adjusting the highlights accordingly.

We will expand on this in the coming days. Here's our starting content:
function selectRegionByName (name) {
    if (name) {
        // zoom the map to this region's already-known info
        const rawdata = REGION_INFO[name];
        MAP.fitBounds([[ rawdata.BBOX_S, rawdata.BBOX_W ], [ rawdata.BBOX_N, rawdata.BBOX_E ]]); 

        // suppress the choropleth by setting a filter that matches nothing
        // enable the black borders by not filtering
        MBOVERLAY._glMap.setFilter('waterregions_choro', ['==', 'name', '']);
        MBOVERLAY._glMap.setFilter('waterregions_black', null]);
    }
    else {
        // zoom the map to the fixed bounds of the whole state
        MAP.fitBounds(WHOLESTATE_BOUNDS);

        // allow the choropleth to show, by not filtering it
        // hide the black outlines by impossible filtering
        MBOVERLAY._glMap.setFilter('waterregions_choro', null);
        MBOVERLAY._glMap.setFilter('waterregions_black', [ '==', 'name', '']);
    }
}
The trick to toggling the layers is easy: to hide a layer, apply to it a filter which matches nothing; to show a layer, clear the filters.

It's simple now, but we will expand on this in the next few postings, so that selectRegionByName() does some more interesting behaviors.

Other Approaches to Toggling


To make the layer invisible, we could have tried a few other mechanisms. Which one works best for you, will vary by your use case. Most often, it's a case of "how would this strategy conflict with the existing map style, and cause unintended side effects?"

Filtering


For us, this layer has no filters at all. We either want to show them all or show none of them. As such, doing it by filter had few questions, and no likelihood of conflicting with our styles or layer stacking.

If you do filter your dataset to form a layer, the setFilter() route may not be for you. That use of setFilter() as shown above really does change the layer, so removes all of your filters in favor of your new filters!

addLayer() and removeLayer()


Use MAP._glMap.getLayer() to stow a reference to the choropleth layer and the black-outline layer, then call MAP._glMap.addLayer() and MAP._glMap.removeLayer() as needed. It works, though is a bit more clunky in my opinion.

Mapbox GL JS API does not support a z-index on layers, so the layer order when they are added back into the map, may not be the same as it was previously. They support a before parameter to help with the stacking order, which may work for you as long as you're not renaming layers nor changing their sequence via Mapbox Studio.

Layer Opacity


Another approach would be to alter the layer's color style via setPaintProperty() so as to give it 0 opacity.
MBOVERLAY.setPaintProperty('waterregions_choro', 'fill-opacity', 0);
MBOVERLAY.setPaintProperty('waterregions_choro', 'line-opacity', 0);
MBOVERLAY.setPaintProperty('waterregions_black', 'line-opacity', 1);
This can work well, as long as you remember to address both the stroke and the fill for all appropriate layers, and as long as you know what opacity would be "the default" when making it visible again.

Thursday, October 12, 2017

Vector Tiles with Mapbox + Leaflet

A couple of weeks back, I finally got to really dig in on Mapbox's implementation of vector tiles, and using vector tiles in Leaflet.

Myself, I almost never get to use Mapbox Studio as the cartography work usually goes to one of my coworkers who has a good eye for that stuff. We're also coming in to vector tiles a bit late, having gotten a green-light that supporting older browsers (IE 8 on XP) and operating systems (Android 4.1) was not a requirement and that a few hours of R&D would be worth the very slick interface behaviors promised by vector tiles. As such, this was a learning experience all around.

The result was a map that is really quite slick, involving behaviors which traditionally would have required a server deployment and a WMS server, and with an outcome more slick than would have been achieved with those methods anyway. Best of all, it stayed well within Mapbox's generous free tier.

The Application

  • Rivers and waterways (line), about 50,000 records spanning the entire state, about 100 MB in size. It's not the whole state, just the waterways considered interesting.
  • Several thousand stream gauge locations (point).
  • Ten defined hydrology regions (polygons) covering the state. These are to be displayed as plain black outlines as well as a choropleth based on some statistic of the waterways.
  • In the UI, a list of those regions. Picking one will zoom to the region, and will also filter the gauges and waterways to that region. The gauges and waterways are tagged with their region, so that filtering should be easy.
  • Tooltips when we mouse over waterways and gauges, and more-detailed popups when they are clicked.
  • Unspecified other Leaflet controls to be added to the map as needed: geocoders, custom legend panel, GreenInfo credits, custom scalebar, probably more.
The datasets above are fairly hefty in size, so simple GeoJSON techniques would not be appropriate here. Failing that, filtering by region would classically be a server-side phenomenon e.g. a WMS service and less-smooth interactivity.

These days, Mapbox is full bore on vector tiles. Conceptually, vector tiles brings the benefits of GeoJSON such as slick interactivity and having attribute data already in memory, with the on-demand loading of tiles, plus the server-side processing involved in generating those vector tiles efficiently by not loading every vertex of every feature.

Let's see what Mapbox can do!

Mapbox

We love Mapbox. They have a habit of changing things fairly often and sometimes breaking things, but a lot more often, they get things just right and really shine.

I rarely get to dig in to Mapbox Studio (they usually give the cartography to someone else), so I had some learning to do:
  • A dataset is called a Tileset
  • Uploading a Tileset can take a couple of hours
    In one case, the spinner never went away but I came back the next morning to find it still spinning. I reloaded the page and it had loaded just fine
  • A collection of datasets plus filtering and classifications-and-colors, organized into layers, is called a Style
    (I think of class-and-color as a style, and a collection of layers as a map, but okay...)
  • Using the Mapbox GL API, layers can be hidden and shown, can have their filtering changed at runtime, etc.

Leaflet + Mapbox GL JS API

Mapbox of course recommends their own Mapbox GL JavaScript API. While their Mapbox API really is just Leaflet with a bunch of additions and so is almost-completely compatible with third-party Leaflet extensions, Mapbox GL API is not like that.

We have all sorts of custom needs which aren't defined yet: custom map controls for legends and geocoders, maybe even Leaflet Draw at some later iteration. We're always skittish about using a semi-proprietary API that won't let us use freely define custom controls and incorporate third-party offerings, since we're all about delivering highly custom solutions.

Fortunately, the folks at Mapbox have a semi-unofficial Leaflet adapter to Mapbox GL. It works pretty well, and almost everything went perfectly as planned: the layer goes onto the map, I can toggle layer visibility and apply filters, I can publish and see the updated Style, etc.

Spoiler Alert: It's Awesome

The application came along well, giving slick interactivity, good performance, filtering capabilities, easy cartography changes as needed, and also the flexibility to incorporate arbitrary Leaflet controls and behaviors.

Along the way I learned a lot, and what I learned is the topic of the next few days' postings.