Building drill-down maps

In this tutorial we'll look at techniques for building various map-based drill-down scenarios.

Requisites

The techniques described in this tutorial rely on event infrastructure. If you're not yet familiar how events work in amCharts 4, we suggest taking a look at "Event Listeners" article first.

We also assume that you have basic knowledge of MapChart and its related components like MapPolygonSeries and MapImageSeries. If that's not the case, make sure you get yourself up to speed here: "Anatomy of a Map Chart".

Base map

Let's start with a very basic world map. We'll use this map as a starter point, as we gradually augment it with additional functionality.

See the Pen amCharts 4: map drill-down tutorial by amCharts team (@amcharts) on CodePen.24419

Handling clicks/taps

The core of all drill-down functionality is a click (or tap) event that originates on some element of the map.

Say, we want to drill-down into a country map when users clicks on it. We'll need to add hit event on a polygon template of our MapPolygonSeries.

polygonSeries.mapPolygons.template.events.on("hit", function(ev) {
  alert("Clicked on " + ev.target.dataItem.dataContext.name);
});
polygonSeries.mapPolygons.template.events.on("hit", function(ev) {
  alert("Clicked on " + ev.target.dataItem.dataContext.name);
});
{
  // ...
  "series": [{
    "type": "MapPolygonSeries",
    // ...
    "mapPolygons": {
      "events": {
        "hit": function(ev) {
          alert("Clicked on " + ev.target.dataItem.dataContext.name);
        }
      }
    }
  }]
}

As you notice, we can access clicked polygon's (country) data via a parameter passed to hit event handler as ev.target.dataItem.dataContext.

Go ahead, try it out:

See the Pen amCharts 4: map drill-down tutorial by amCharts team (@amcharts) on CodePen.24419

Implementing drill-down

Now that we know how to "catch" clicks, let's make it do something useful.

To make drill-down seem smooth, we need to implement two things:

  1. Zoom in on the clicked country.
  2. Display clicked country map.

Zooming in

As we already saw earlier in this tutorial, hit event handler function receives a reference to the clicked object itself via ev.target.

All we need to do is to use MapChart function zoomToMapObject(target).

polygonSeries.mapPolygons.template.events.on("hit", function(ev) {
  let chart = ev.target.series.chart;
  chart.zoomToMapObject(ev.target);
});
polygonSeries.mapPolygons.template.events.on("hit", function(ev) {
  var chart = ev.target.series.chart;
  chart.zoomToMapObject(ev.target);
});
{
  // ...
  "series": [{
    "type": "MapPolygonSeries",
    // ...
    "mapPolygons": {
      "events": {
        "hit": function(ev) {
          var chart = ev.target.series.chart;
          chart.zoomToMapObject(ev.target);
        }
      }
    }
  }]
}

Transitioning to new map

Actually, there are a lot of different ways to and techniques to transition to a new map on click.

We'll look at some of them in due time. For now let's start with the easiest, and the most straightforward: replacing geodata with a new one.

Let's say we want to transition to a state-level map, when user clicks on United States.

Since we do have access to related data, we can implement checks in hit handler, then replace geodata with required one.

polygonSeries.mapPolygons.template.events.on("hit", function(ev) {
  // ...

  if (ev.target.dataItem.dataContext.id == "US") {
    setTimeout(function() {
      chart.geodata = am4geodata_usaLow;
      chart.goHome(0);
    }, chart.zoomDuration + 100);
  }
});
polygonSeries.mapPolygons.template.events.on("hit", function(ev) {
  // ...

  if (ev.target.dataItem.dataContext.id == "US") {
    setTimeout(function() {
      chart.geodata = am4geodata_usaLow;
      chart.goHome(0);
    }, chart.zoomDuration + 100);
  }
});
{
  // ...
  "series": [{
    "type": "MapPolygonSeries",
    // ...
    "mapPolygons": {
      "events": {
        "hit": function(ev) {
          // ...

          if (ev.target.dataItem.dataContext.id == "US") {
            setTimeout(function() {
              chart.geodata = am4geodata_usaLow;
              chart.goHome(0);
            }, chart.zoomDuration + 100);
          }
        }
      }
    }
  }]
}

There are a few things going on in the above code that're worth explaining.

First of all, since we have zoom set up to clicked polygon, we want to give it some time to finish before we replace the map, hence the setTimeout().

Also, when we replace the geodata to new map, our MapChart is still in the zoomed in state, so we need to zoom int out using its goHome() method. The zero as a parameter ensures that zoom out is instant.

NOTE Needless to say, that the "usaLow" map needs to be loaded for the above code to work.

See the Pen amCharts 4: map drill-down tutorial by amCharts team (@amcharts) on CodePen.24419

Cross-faded map transitions

The single-series technique shown above works, but it feels a bit... brusque, for the lack of a better word.

Let's look at another technique: two-series drill-down.

It works like this:

  1. We have two MapPolygonSeries: one for world map and the other for country map. The latter starts off hidden.
  2. When user clicks on a country on world map we:
    • Zoom in on that country.
    • Fade out world series.
    • Replace geodata of country series with the target country data.
    • Fade in country series.
// World series
let worldSeries = chart.series.push(new am4maps.MapPolygonSeries());
worldSeries.exclude = ["AQ"];
worldSeries.useGeodata = true;
// ...

// Country series
let countrySeries = chart.series.push(new am4maps.MapPolygonSeries());
// ...
countrySeries.hide();

// Add hit events
worldSeries.mapPolygons.template.events.on("hit", function(ev) {
  // Get chart object
  let chart = ev.target.series.chart;

  // Zoom to clicked element
  chart.zoomToMapObject(ev.target);

  // Transition to state map of it's U.S.
  if (ev.target.dataItem.dataContext.id == "US") {
    worldSeries.hide();
    countrySeries.geodata = am4geodata_usaLow;
    countrySeries.show();
  }
});
// World series
var worldSeries = chart.series.push(new am4maps.MapPolygonSeries());
worldSeries.exclude = ["AQ"];
worldSeries.useGeodata = true;
//...

// Country series
var countrySeries = chart.series.push(new am4maps.MapPolygonSeries());
// ...
countrySeries.hide();

// Add hit events
worldSeries.mapPolygons.template.events.on("hit", function(ev) {
  // Get chart object
  var chart = ev.target.series.chart;

  // Zoom to clicked element
  chart.zoomToMapObject(ev.target);

  // Transition to state map of it's U.S.
  if (ev.target.dataItem.dataContext.id == "US") {
    worldSeries.hide();
    countrySeries.geodata = am4geodata_usaLow;
    countrySeries.show();
  }
});
{
  // ...
  "series": [{
    // World series
    "type": "MapPolygonSeries",
    "exclude": ["AQ"],
    "useGeodata": true,
    "mapPolygons": {
      // ...
      "events": {
        "hit": function(ev) {
          // Get chart object
          var chart = ev.target.series.chart;
          var worldSeries = chart.series.getIndex(0);
          var countrySeries = chart.series.getIndex(1);

          // Zoom to clicked element
          chart.zoomToMapObject(ev.target);

          // Transition to state map of it's U.S.
          if (ev.target.dataItem.dataContext.id == "US") {
            worldSeries.hide();
            countrySeries.geodata = am4geodata_usaLow;
            countrySeries.show();
          }
        }
      }
    }
  }, {
    // Country series
    "type": "MapPolygonSeries",
    "hidden": true,
    // ...
  }]
}

NOTE For the purpose of this demo, we also enable "animated" theme. It makes hiding and revealing objects fade out and in, rather then disappear or appear abruptly.

Here's how this works now (click on the U.S.):

See the Pen amCharts 4: map drill-down tutorial by amCharts team (@amcharts) on CodePen.24419

Drill-up

Of course, no drill-down setup is complete if we leave our users with no way to go back. Let's add this one final ingredient.

Code-wise, going back a level would work similarly as drill-down, just in reverse:

  1. Hide country series.
  2. Show world series.
  3. Zoom out.

BTW, we don't need to set geodata for world series, since it's already there.

All need to do now is to add a button to click on for that the above process to take place.

Let's add an instance of ZoomOutButton, which is a ready-made button control, and perfectly suitable for our purposes.

The drill-up code will take place in handler of button's hit event.

let back = chart.createChild(am4core.ZoomOutButton);
back.align = "right";
back.hide();
back.events.on("hit", function(ev) {
  worldSeries.show();
  chart.goHome();
  countrySeries.hide();
  back.hide();
});
var back = chart.createChild(am4core.ZoomOutButton);
back.align = "right";
back.hide();
back.events.on("hit", function(ev) {
  worldSeries.show();
  chart.goHome();
  countrySeries.hide();
  back.hide();
});
{
  // ...
  "children": [{
    "type": "ZoomOutButton",
    "id": "back",
    "forceCreate": true,
    "align": "right",
    "hidden": true,
    "events": {
      "hit":  function(ev) {
        var chart = ev.parent;
        var worldSeries = chart.series.getIndex(0);
        var countrySeries = chart.series.getIndex(1); 
        worldSeries.show();
        chart.goHome();
        countrySeries.hide();
        ev.target.hide();
      } 
    }
  }]
}

Oh, and since we start off with a zoom out button hidden, we need to update our drill-down code to reveal it when needed:

worldSeries.mapPolygons.template.events.on("hit", function(ev) {
  // ...
  if (ev.target.dataItem.dataContext.id == "US") {
    // ...
    back.show();
  }
});
worldSeries.mapPolygons.template.events.on("hit", function(ev) {
  // ...
  if (ev.target.dataItem.dataContext.id == "US") {
    // ...
    back.show();
  }
});
{
  // ...
  "series": [{
    // ...
    "mapPolygons": {
      // ...
      "events": {
        "hit": function(ev) {
          // ...
          if (ev.target.dataItem.dataContext.id == "US") {
            // ...
            chart.map.getKey("back").show();
          }
        }
      }
    }
  }, {
    // Country series
    "type": "MapPolygonSeries",
    "hidden": true,
    // ...
  }]
}

And here's the full working example:

See the Pen amCharts 4: map drill-down tutorial by amCharts team (@amcharts) on CodePen.24419

Loading maps dynamically

So far we have been using two pre-loaded maps: World and U.S.

This might be OK, if we only have to drill down to a single country, or two.

However, if we have many targets, loading 150+ maps would be overkill, so we need another solution.

When user clicks on a country, we will need to load the map dynamically, then transition smoothly to it.

From the functionality side, this works similarly what we already seen, but with a little modification:

  1. Zoom to clicked country.
  2. [Extra step] Initiate loading of "JSON" version of the target map and wait until its loaded.
  3. Set geodata to country series.
  4. Reveal country series.
  5. Hide world series.
countrySeries.geodataSource.events.on("done", function(ev) {
  worldSeries.hide();
  countrySeries.show();
});

worldSeries.mapPolygons.template.events.on("hit", function(ev) {
  // ...
  let map;
  switch(ev.target.dataItem.dataContext.id) {
    case "US":
      map = "usaLow";
      break;
    case "FR":
      map = "franceLow";
      break;
  }

  if (map)
    countrySeries.geodataSource.url = "https://www.amcharts.com/lib/4/geodata/json/" + map + ".json";
    countrySeries.geodataSource.load();
  }
});
countrySeries.geodataSource.events.on("done", function(ev) {
  worldSeries.hide();
  countrySeries.show();
});

worldSeries.mapPolygons.template.events.on("hit", function(ev) {
  // ...
  var map;
  switch(ev.target.dataItem.dataContext.id) {
    case "US":
      map = "usaLow";
      break;
    case "FR":
      map = "franceLow";
      break;
  }

  if (map)
    countrySeries.geodataSource.url = "https://www.amcharts.com/lib/4/geodata/json/" + map + ".json";
    countrySeries.geodataSource.load();
  }
});
{
  // ...
  "series": [{
    // ...
    "mapPolygons": {
      // ...
      "events": {
        "hit": function(ev) {
          var chart = ev.target.series.chart;
          var worldSeries = chart.series.getIndex(0);
          var countrySeries = chart.series.getIndex(1);

          // ...
          var map;
          switch(ev.target.dataItem.dataContext.id) {
            case "US":
              map = "usaLow";
              break;
            case "FR":
              map = "franceLow";
              break;
          }

          if (map)
            countrySeries.geodataSource.url = "https://www.amcharts.com/lib/4/geodata/json/" + map + ".json";
            countrySeries.geodataSource.load();
          }
        }
      }
    }
  }, {
    // Country series
    "type": "MapPolygonSeries",
    "hidden": true,
    // ...
    "geodataSource": {
      "events": {
        "done": function() {
          var chart = ev.target.series.chart;
          var worldSeries = chart.series.getIndex(0);
          var countrySeries = chart.series.getIndex(1);
          worldSeries.hide();
          countrySeries.show();
        }
      }
    }
  }]
}

There are few things to note about the above code.

NOTE #1 Notice we don't set series geodata directly. Instead we are setting the url property of its geodataSource. The latter contains instance of a DataSource - an object that is capable of loading external data files, in this case map files in GeoJSON format.

NOTE #2 We are loading maps in GeoJSON format directly, not as a source to the script tag. This is why we are using .json file extension, rather than .js.

Also, the switch use to select proper map file is probably not the most efficient way to do it. Most probably you will have some sort of array or object to map country id to its target map file, like in the example below:

See the Pen amCharts 4: map drill-down tutorial by amCharts team (@amcharts) on CodePen.24419