Interactive Mapping with Tangram

Hi there.

We’re back with another installment of Make Your Own [  ].

Previously, we’ve looked at how our data can be filtered, styled, and displayed in a Tangram map. But what if you want to create a full interactive mapping experience for your users? Why, Tangram does that, too!

Today we’re going to be looking at a few different ways we can interact with our data: we’ll make a simple layer toggle, add a popup on click, and add a highlighted boundary to a selected feature. Yes, it’s a lot, but just think of all the cool interactions you can add to your next Tangram map…

You’ll really be bringing your map sandwich to life!

Make your map sandwich come alive!

We’ll be building upon our work from last time, so if you haven’t yet read the first few posts, I recommend starting there:

In the last few posts, we focused primarily on the Tangram scene file (typically called scene.yaml). In today’s post, we’ll be focusing more heavily on the JavaScript side of things and will largely be working within our index.html file.

Ready to jump in? … That’s the spirit!

We’ll begin with the following two files.

index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>San Juan Island Geology - Interactivity</title>
    <meta charset="utf-8">
    <link rel="stylesheet" href="https://www.nextzen.org/js/nextzen.css">
    <script src="https://www.nextzen.org/js/nextzen.min.js"></script>
    <style>
      #map {
        height: 100%;
        width: 100%;
        position: absolute;
      }
    html,body{margin: 0; padding: 0}
  </style>
  </head>
  <body>
    <div id="map"></div>
    <script>
      // Mapzen API key (replace key with your own)
      // To generate your own key, go to https://mapzen.com/developers/
      L.Mapzen.apiKey = 'mapzen-JA21Wes';

      var san_juan_island = [48.5326, -123.0879];

      var map = L.Mapzen.map('map', {
        center: san_juan_island,
        zoom: 13,
        scene: 'scene.yaml',
      });

      // Add attribution for NPS geology data
      map.attributionControl.addAttribution('Geology data &copy; <a href="https://www.nps.gov">NPS</a>');

      // Mapzen Search box (replace key with your own)
      // To generate your own key, go to https://mapzen.com/developers/
      var geocoder = L.Mapzen.geocoder('mapzen-JA21Wes');
      geocoder.addTo(map);

      // ADD LAYER TOGGLE

      // ADD SELECTION EVENTS

      // ADD HIGHLIGHT

      // ADD TANGRAMLOADED LISTENER

    </script>
  </body>
</html>

UPDATE March 1, 2017: A Mapzen developer API key is now required for mapzen.js. We’ve updated the Make Your Own series to include a demo key. Generate your own free API key at https://mapzen.com/developers/.

and scene.yaml:

import: https://mapzen.com/carto/walkabout-style/3/walkabout-style.zip

styles:
    _alpha_polygons:
        base: polygons
        blend: multiply
    _dashed_lines:
        base: lines
        dash: [3, 1]
        dash_background_color: rgb(149, 188, 141)

sources:
    _nps_boundary:
        type: GeoJSON
        url: https://gist.githubusercontent.com/rfriberg/684645c22f495b4a46f29fb312b6d268/raw/843ed38a3920ed199082636fe198ba995f5cfc04/san_juan_nhp.geojson

    _nps_geology:
        type: GeoJSON
        url: https://gist.githubusercontent.com/rfriberg/3c09fe3afd642224da7cd70aff1c1e70/raw/1f1df59f4cb4e82d7ea23452c789bc99c299a5cb/san_juan_nhp_geology.geojson
        generate_label_centroids: true

layers:
    _national_park:
        data: { source: _nps_boundary }
        draw:
            _dashed_lines:
                width: [[8, 0.5px], [18, 5px]]
                color: '#518946'
                order: global.sdk_order_over_everything_but_text_1

    _geology:
        data: { source: _nps_geology }
        filter:
            all:
                - { $zoom: { min: 10 } }
                - not: { GLG_SYM: water }
        draw:
            _alpha_polygons:
                order: global.sdk_order_over_everything_but_text_0
                color: |
                    function() {
                        // Note: this is a block of JavaScript so we can use JS comment syntax
                        var category = feature.GLG_SYM;
                        var color = category == 'Qa'       ? '#FFF79A' :
                                    category == 'Qb'       ? '#FFF46E' :
                                    category == 'Qd'       ? '#fff377' :
                                    category == 'Qf'       ? '#dddddd' :
                                    category == 'Qp'       ? '#EAC88D' :
                                    category == 'Qgdm'     ? '#FCBB62' :
                                    category == 'Qgdm(es)' ? '#FEE9BB' :
                                    category == 'Qgdm(e)'  ? '#E8A121' :
                                    category == 'Qgom(e)'  ? '#EAB564' :
                                    category == 'Qgom'     ? '#FECE7A' :
                                    category == 'Qgd'      ? '#FEDDA3' :
                                    category == 'Qgt'      ? '#FCBB62' :
                                    category == 'KJmm(c)'  ? '#86C879' :
                                    category == 'KJm(ll)'  ? '#9FD08A' :
                                    category == 'JTRmc(o)' ? '#27BB9D' :
                                    category == 'TRn'      ? '#ED028C' :
                                    category == 'TRPMv'    ? '#F172AC' :
                                    category == 'TRPv'     ? '#F499C2' :
                                    category == 'PDmt'     ? '#40C7F4' :
                                    category == 'pPsh'     ? '#9BA5BE' :
                                    category == 'pDi'      ? '#848FC7' :
                                    category == 'pDit(t)'  ? '#B28ABF' :
                                    '#000';

                        return color;
                    }

        _geology_labels:
          filter: { label_placement: true,  $zoom: { min: 13 } }
          draw:
              text:
                  text_source: GLG_SYM
                  font:
                      fill: rgba(130, 84, 41, 0.9)
                      size: [[13, 10px], [20, 24px]]
                      weight: bold
                      stroke:
                          color: rgba(242, 218, 193, 0.3)
                          width: 3

    # Override Walkabout's layers
    landuse:
        visible: false
    roads:
        minor_road:
            visible: false

I made a couple of changes to our index.html file since you’ve seen it last: I removed the custom positioning of the zoom control and added an attribution for our geology data. But for the most part, it should look much as you remember it from our last post.

Layer Switcher

Let’s start by creating a simple layer switcher for our geology layer. It will be nothing more than a checkbox that toggles the visibility of our geology layer, but that seems like a good place to start.

We’re going to extend Leaflet’s Control class to create our switcher. This will allow us to use Leaflet’s positioning option as well as a few built-in methods.

In the <script> tag of your index.html file, add the following under // ADD LAYER TOGGLE:

      /// ADD LAYER TOGGLE
      var LayerToggleControl = L.Control.extend({
        options: {
          position: 'topright'
        },

        onAdd: function () {
          var container = L.DomUtil.create('div', 'layer-control');
          container.innerHTML = '<label><input id="layer_toggle" type="checkbox" checked> Geology layer</label>'

          return container;
        },

      });
      var toggleControl = new LayerToggleControl();
      map.addControl(toggleControl);

This will add a checkbox and label to the top right corner of your map.

Note: the onAdd function we’re using is a built-in method that Leaflet calls once the control is added to the map.

To help our checkbox stand out against the map, add this css to your <style> section at the top of your page:

      .layer-control {
        background: rgba(255, 255, 255, 0.85);
        padding: 0 10px;
        border-radius: 5px;
      }

Now let’s make the control actually do something. In the onAdd method, we’ll add a listener to listen for clicks. Then add a click handler (called toggleOnClick) to process those clicks:

      // ADD LAYER TOGGLE
      var LayerToggleControl = L.Control.extend({
        options: {
          position: 'topright'
        },

        onAdd: function () {
          var container = L.DomUtil.create('div', 'layer-control');
          container.innerHTML = '<label><input id="layer_toggle" type="checkbox" checked> Geology layer</label>'

          L.DomEvent.on(container, 'click', this.toggleOnClick);
          return container;
        },

        toggleOnClick: function (e) {
          var showLayer = document.getElementById('layer_toggle').checked;
          alert('show layer? ' + showLayer);
        }
      });

In this example, all we’re doing is checking whether our checkbox is checked. We’ll use this value to determine whether we should show (true) or hide (false) our layer.

Ok, now we need a way to tell Tangram whether or not we want to display this particular layer. We’ll do that by setting the visibility parameter for that layer. You may remember this parameter from a previous post, when we temporarily hid our national park boundary layer in our scene file. There is also a way to update this parameter using JavaScript.

To do so, we’ll need access to Tangram’s scene object.

Currently, mapzen.js fires a “tangramloaded” event once Tangram is loaded and available to our page. We’ll add a listener to listen for this event, and grab the scene object once it’s available. To make sure the scene object is available outside of this listener, we’ll assign it to a global variable called scene. (I’m sure that’s not confusing at all.)

At the bottom of our <script> section, where it says // ADD TANGRAMLOADED LISTENER, let’s add our global scene variable and the listener:

      // ADD TANGRAMLOADED LISTENER
      var scene;
      map.on('tangramloaded', function(e) {
        var tangramLayer = e.tangramLayer;
        scene = tangramLayer.scene;
      });

Great. Now back in our custom Leaflet control, we can use this scene object in our toggleOnClick() method. Tangram (and its scene object) loads pretty fast, but we’ll still add a simple check to make sure it’s loaded and handle any quick-clicker friends who may be viewing our map:

        toggleOnClick: function (e) {
          var showLayer = document.getElementById('layer_toggle').checked;

          if (scene) {
            // do something
          }
        }

Now that we have the scene object available, we can do quite a bit to our Tangram map. In fact, here’s the scene object documentation once more, in case you want to read up on all of the methods and properties you now have access to.

The scene property we’ll use the most is the config object. The config object is the JavaScript object version of our entire scene file. So, anything we could update in our scene file can also be accessed via the scene.config JavaScript object.

For instance, if we want to change the visibility of our geology layer, we could make the change in the scene file:

layers:
    # San Juan geology layer (polygons and labels)
    _geology:
        visible: false
        filter: ...
        draw: ...

Or we can do so dynamically using JavaScript:

scene.config.layers._geology.visible = false;

Note: the same method works on sublayers, as well:

scene.config.layers._geology.draw._geology_labels.visible = false;

After making changes to scene.config, we then call scene.updateConfig() to update the scene with all of our changes. So, putting it all together, we now have a toggleOnClick method that will hide and show our geology layer based on a checkbox click:

        toggleOnClick: function (e) {
          var showLayer = document.getElementById('layer_toggle').checked;

          if (scene) {
            scene.config.layers._geology.visible = showLayer;
            scene.updateConfig();
          }
        }

Honestly. I’d call that a successful day. You could knock off right here and rest happily with your newfound knowledge of how to interact with a Tangram map. Go ahead. Tell your boss I gave you the rest of the day off.

Ah, right. Popups do sound fun, don’t they? Being able to dig out data from our layer and display it in a popup (or a tooltip or any other html element)… who really wants to miss that? Not me!

Popups

We’ll start by adding a basic Leaflet popup to the top of our script section:

var popup = L.popup();

This won’t show up on your map, as it needs a location (at minimum), which we’ll set later. For now, we just want a generic popup available.

Next, we’ll set up a click handler to capture any map clicks. Tangram provides two selection event handlers: hover and click. In our case, the easiest way to add selection events is within our “tangramloaded” listener, when we have access to the tangramLayer object. At that point, we can use Tangram’s setSelectionEvents() method.

Selection Event: Click

Let’s go ahead and a click selection event to our “tangramloaded” listener:

      // ADD TANGRAMLOADED LISTENER
      var scene;
      map.on('tangramloaded', function(e) {
        var tangramLayer = e.tangramLayer;
        scene = tangramLayer.scene;

        tangramLayer.setSelectionEvents({
          click: function(selection) {
            alert('Clicked!');
          }
        });

      });

If you’re following along, your map will now display a “Clicked!” alert any time you click on the map. I know it’s not much. Baby steps, Bob!

Our callback function is merely an alert at the moment, but it will grow. So before it gets unwieldy, let’s pull that function out of our listener and call it onMapClick.

      // ADD SELECTION EVENTS
      function onMapClick(selection) {
        alert('Clicked!');
      }

      // ADD TANGRAMLOADED LISTENER
      var scene;
      map.on('tangramloaded', function(e) {
        var tangramLayer = e.tangramLayer;
        scene = tangramLayer.scene;

        tangramLayer.setSelectionEvents({
          click: onMapClick
        });

      });

Much better.

Now let’s make our onMapClick handler actually handle our click:

      // ADD SELECTION EVENTS
      function onMapClick(selection) {
        if (selection.feature) {
          var latlng = selection.leaflet_event.latlng;
          var label = selection.feature.properties.GLG_SYM;
          showPopup(latlng, label);
        }
      }

      function showPopup(latlng, label) {
        popup
          .setLatLng(latlng)
          .setContent('<p>' + label + '</p>')
          .openOn(map);
      }

What did I tell you? It grew fast, right? Let me walk you through what we just did.

Our select event callback function (which we’ve called onMapClick) accepts an object from Tangram that was “selected” when we clicked on it. The selected object (selection) looks like this:

{ feature, changed, pixel, leaflet_event }

From the leaflet_event property, we can get the latitude/longitude of our click. And from the feature property, we can get the list of GeoJSON properties for that feature. If you recall from our Filters and Functions post, each of our geology polygons comes with a set of GeoJSON properties:

{
    "type": "Feature",
    "properties": {
        "FUID": 1,
        "GLG_SYM": "KJmm(c)",
        "SRC_SYM": "KJm(c)",
        "SORT_NO": 13.0,
        "NOTES": "NA",
        "GMAP_ID": 74832,
        "HELP_ID": "KJmm(c)",
        "SHAPE_Leng": 137.95199379300001,
        "SHAPE_Area": 954.47301117400002
    },
    "geometry": ...
}

Once again, we’ll use the GLG_SYM property as the text to display in our popup.

With latlng and label in hand, we can now set the location of our popup, set the content of our popup, and display it on our map.

If you’re really following along, you may notice that nothing is actually happening. Reload. Click on the map. Nothing happens. That’s because there’s one last thing we need to do. And that is to tell Tangram that our geology polygons should be query-ablequeriable… queryable. (Nailed it.)

We can do that by setting the interactive parameter of our geology polygons. We don’t need to update this dynamically, so let’s add the new parameter directly to our scene.yaml file:

layers:
    # San Juan geology layer (polygons and labels)
    _geology:
        data: ...
        filter: ...
        draw:
            _alpha_polygons:
                interactive: true

This interactive: true parameter enables Tangram’s selection capability for those objects. Now, when we reload our map and click on a geology polygon, we should be seeing a simple popup displaying the appropriate geology symbol. When you click on a feature that does not have interactive: true (e.g., the water), no object will be selected and so no popup will appear.

Selection Event: Hover

While we’re on the subject of selection events, let’s add a handler for hover events, too:

      function onMapHover(selection) {
        // do something
      }

      // ADD TANGRAMLOADED LISTENER
      var scene;
      map.on('tangramloaded', function(e) {
        var tangramLayer = e.tangramLayer;
        scene = tangramLayer.scene;

        tangramLayer.setSelectionEvents({
          click: onMapClick,
          hover: onMapHover
        });

      });

Now, every time our cursor hovers over the map, onMapHover will be called. It will be called a lot, so be careful in how you use this. (With great power comes… well, you know the rest.)

In this case, I just want to know when the cursor is hovering over a queryable object. We can do that with just a simple check for the selection.feature object:

      function onMapHover(selection) {
        document.getElementById('map').style.cursor = selection.feature ? 'pointer' : '';
      }

Now, when our cursor hovers over a queryable object, it will become a “pointer” and we’ll know it’s clickable.

Let’s see our click and hover events in action:

Feature Highlighting

Let’s look at one last thing before we wrap up. I’d like to add some highlighting to our features to help them stand out when they’ve been clicked. Currently, this requires a couple of steps to get working in Tangram.

We’ll start in our scene.yaml file.

Let’s add a new sublayer to our _geology layer to capture the style for our selected feature. We’ll call it _geology_highlight:

        _geology_highlight:
            draw:
                lines:
                    width: 2px
                    color: yellow
                    order: global.sdk_order_over_everything_but_text_2

As it is now, this will display a yellow border around all of our geology polygons. To limit the style to a single polygon, we’ll need to set up a filter. Let’s take another look at those GeoJSON properties:

{
    "type": "Feature",
    "properties": {
        "FUID": 1,
        "GLG_SYM": "KJmm(c)",
        "SRC_SYM": "KJm(c)",
        "SORT_NO": 13.0,
        "NOTES": "NA",
        "GMAP_ID": 74832,
        "HELP_ID": "KJmm(c)",
        "SHAPE_Leng": 137.95199379300001,
        "SHAPE_Area": 954.47301117400002
    },
    "geometry": ...
}

The FUID property is a unique identifier. So, if we only want to highlight a single polygon, we should filter on that FUID property:

        _geology_highlight:
            filter: { FUID: 108 }
            draw: ...

If you update your map now, you should see a single highlighted polygon in the center of the island.

But what if we want to highlight all polygons of the same rock type? We could do that by using the same GLG_SYM property we’ve used for our styling, labels, and popups:

        _geology_highlight:
            filter: { GLG_SYM: 'KJmm(c)' }
            draw: ...

Now, when you reload, you’ll see all of the green KJmm(c) polygons highlighted.

Great! Let’s go with that. Some of those polygons are small and hard to see, so highlighting will be a good way to quickly see where each rock unit is exposed across the island and to facilitate the interpretation of the island’s geologic structures.

I mean, if you’re into that type of thing.

We’ll move back to our JavaScript in just a second, but first, let’s make it a little easier to update this filter. At the top of your scene.yaml file, add a global variable called _highlight:

global:
    _highlight: false

Note: I set the global variable to false so nothing will appear on first load, but really anything that’s not a valid filter value will work here. If you want to test that it’s working, go ahead and set it to KJmm(c).

Then, update your filter to pull in this new global variable:

        _geology_highlight:
            filter: { GLG_SYM: global._highlight }
            draw: ...

Now all we need to do from from the JavaScript side of things is to update the global _highlight. This will be a little cleaner than passing in an entire filter object.

Ok. Back to index.html.

Let’s add a function that updates our global variable, which is stored in the scene.config object we used earlier. Once again, we’ll need to call scene.updateConfig() in order to update the scene with our new changes:

      // ADD HIGHLIGHT
      function highlightUnit(geology_symbol) {
        scene.config.global._highlight = geology_symbol;
        scene.updateConfig();
      }

Then, we just need to call this function from within our onMapClick callback. We already know how to pull out the GLG_SYM property from our selected feature, so this part should feel familiar:

      // ADD SELECTION EVENTS
      function onMapClick(selection) {
        if (selection.feature) {
          var latlng = selection.leaflet_event.latlng;
          var label = selection.feature.properties.GLG_SYM;

          showPopup(latlng, label);
          highlightUnit(label);
        } else {
          highlightUnit(false);
        }
      }

And that’s it! Here’s the map in all of its interactive glory:

ooooh! clapping! and fanfare!

The final code for this exercise can be found on bl.ocks.org.

Thanks for following along with yet another installment of Make Your Own [  ].

If there are topics you’d like to see covered in a future post, let us know.

And, as always, if you have questions or want to show off something you’ve made with Mapzen, drop us a line. We love to hear from you!

~~~

Check out additional tutorials from the Make Your Own series: