Map Image Drill-Down

A store locator that drills down: bubbles count the stores of a U.S. retail chain in each state. Click a state’s bubble to see its cities, then a city’s to see each store.

  • Try it:
  • Click a bubble to drill down
  • Click the minus button to go back
Get the code JavaScript
About this map

When to drill into points

Hundreds of points on one map turn into a blur, so this one starts with a bubble per state and opens the detail on click: cities, then single stores. Each level is a point series of its own, made the first time it’s opened, from one file of data. The same pattern suits any store, office or dealer finder.

Good for

  • Store, office and dealer finders
  • Thousands of locations in a hierarchy
  • Counts by region with the detail a click away

Think twice when

  • No natural levels like state and city: cluster the points
  • Comparing regions exactly: a bar chart
  • Readers looking for one address: add a search
For developers

Get the code.

Built with amCharts 5, a JavaScript library. The code includes your theme and renderer (canvas or SVG), but not the changes you make with the controls.

// =================================
// Create map chart
// =================================

// Create root and chart
var root = am5.Root.new("chartdiv");

// Set themes
root.setThemes([
  am5themes_Animated.new(root),
  am5themes_Responsive.new(root)
]);

// Colors from the theme, so the map follows the theme
var colors = am5.ColorSet.new(root, {});

var chart = root.container.children.push(
  am5map.MapChart.new(root, {
    minZoomLevel: 0.5, // can zoom out to half the fitted size
    // go to the home view once the map is fitted
    autoHome: true,
    panX: "rotateX",
    projection: am5map.geoAlbersUsa() // puts Alaska and Hawaii next to the other states
  })
);

// Create polygon series
var polygonSeries = chart.series.push(
  am5map.MapPolygonSeries.new(root, {
    geoJSON: am5geodata_usaLow // the U.S. states, in low detail
  })
);

polygonSeries.mapPolygons.template.setAll({
  tooltipText: "{name}" // the state's name on hover
});

polygonSeries.mapPolygons.template.states.create("hover", {
  fill: colors.getIndex(9) // the hovered state turns another theme color
});

// a round zoom-out button, top right; the tooltip container keeps it above the map
var zoomOut = root.tooltipContainer.children.push(am5.Button.new(root, {
  x: am5.p100,
  y: 0,
  centerX: am5.p100,
  centerY: 0,
  paddingTop: 18, // padding that makes the button 36px square around the 12px minus sign
  paddingBottom: 18,
  paddingLeft: 12,
  paddingRight: 12,
  dx: -20,             // 20px in from the right edge...
  dy: 20,              // ...and 20px down from the top
  themeTags: ["zoom"], // styled like the map's zoom buttons
  icon: am5.Graphics.new(root, {
    themeTags: ["icon"],      // styled as a button icon
    strokeOpacity: 0.7,       // a slightly faded line
    draw: function(display) { // a minus sign: a 12px flat line
      display.moveTo(0, 0);
      display.lineTo(12, 0);
    }
  })
}));

zoomOut.get("background").setAll({
  cornerRadiusBL: 40, // corners rounded enough to make a circle
  cornerRadiusBR: 40,
  cornerRadiusTL: 40,
  cornerRadiusTR: 40
});
// zooming out goes back to the whole country and its state bubbles
zoomOut.events.on("click", function() {
  if (currentSeries) {
    currentSeries.hide();
  }
  chart.goHome();
  zoomOut.hide();
  currentSeries = regionalSeries.US.series;
  currentSeries.show();
});
zoomOut.hide(); // hidden until a bubble is clicked

// =================================
// Set up point series
// =================================

// Load store data: the stores of a U.S. retail chain, each with its city, state, coordinates and a count
am5.net.load("https://www.amcharts.com/wp-content/uploads/assets/maps/TargetStores.json").then(function(result) {
  var stores = am5.JSONParser.parse(result.response);
  setupStores(stores);
});

var regionalSeries = {}; // bubble data and series for the country, each state and each city
var currentSeries;       // the bubbles on screen now

// Parses data and creates map point series for country and state level
function setupStores(data) {

  // Init country-level series
  regionalSeries.US = {
    markerData: [],
    series: createSeries("stores") // sized by the number of stores
  };

  // Set current series
  currentSeries = regionalSeries.US.series;

  // Process data
  am5.array.each(data.query_results, function(store) {

    // Get store data
    var store = {
      state: store.MAIL_ST_PROV_C,
      long: am5.type.toNumber(store.LNGTD_I),
      lat: am5.type.toNumber(store.LATTD_I),
      location: store.co_loc_n,
      city: store.mail_city_n,
      count: am5.type.toNumber(store.count)
    };

    // Process state-level data
    if (regionalSeries[store.state] == undefined) {
      var statePolygon = getPolygon("US-" + store.state);
      if (statePolygon) {

        // the state's bubble goes to the visual center of its shape
        var centroid = statePolygon.visualCentroid();

        // Add state data
        regionalSeries[store.state] = {
          target: store.state, // the key of this state's own drill-down level
          type: "state",
          name: statePolygon.dataItem.dataContext.name,
          count: store.count,
          stores: 1, // the first store found; more are counted below
          state: store.state,
          markerData: [],
          geometry: {
            type: "Point",
            coordinates: [centroid.longitude, centroid.latitude]
          }
        };
        // the state's bubble goes on the country level
        regionalSeries.US.markerData.push(regionalSeries[store.state]);

      }
      else {
        // State not found
        return;
      }
    }
    else {
      regionalSeries[store.state].stores++;             // one more store in the state...
      regionalSeries[store.state].count += store.count; // ...and its count added
    }

    // Process city-level data
    if (regionalSeries[store.city] == undefined) {
      regionalSeries[store.city] = {
        target: store.city,
        type: "city",
        name: store.city,
        count: store.count,
        stores: 1,
        state: store.state,
        markerData: [],
        geometry: {
          type: "Point",
          coordinates: [store.long, store.lat]
        }
      };
      // the city's bubble goes on its state's level
      regionalSeries[store.state].markerData.push(regionalSeries[store.city]);
    }
    else {
      regionalSeries[store.city].stores++;
      regionalSeries[store.city].count += store.count;
    }

    // Process individual store
    regionalSeries[store.city].markerData.push({
      name: store.location,
      count: store.count,
      stores: 1,
      state: store.state,
      geometry: {
        type: "Point",
        coordinates: [store.long, store.lat]
      }
    });

  });
  regionalSeries.US.series.data.setAll(regionalSeries.US.markerData); // show the state bubbles first
}

// Finds polygon in series by its id
function getPolygon(id) {
  var found;
  polygonSeries.mapPolygons.each(function(polygon) {
    if (polygon.dataItem.get("id") == id) {
      found = polygon;
    }
  })
  return found;
}

// All counts on the bubbles share this template, so they can be shown or hidden at once, on every level
var labelTemplate = am5.Template.new({
  fill: root.interfaceColors.get("background"), // text in the background color, to stand out on the bubbles
  centerX: am5.p50,   // centered on the bubble...
  centerY: am5.p50,   // ...both ways...
  textAlign: "center" // ...and line by line
});

// Creates series with heat rules
function createSeries(heatfield) {

  // Create point series
  var pointSeries = chart.series.push(
    am5map.MapPointSeries.new(root, {
      valueField: heatfield,    // the field the bubble size comes from
      calculateAggregates: true // works out the lowest and highest value, which the heat rule needs
    })
  );

  // Each series has a bubble template of its own, the target of its heat rule
  var circleTemplate = am5.Template.new({
    fill: root.interfaceColors.get("alternativeBackground"), // bubbles in the contrast color...
    fillOpacity: 0.7,           // ...slightly see-through
    cursorOverStyle: "pointer", // a hand pointer: the bubbles can be clicked
    tooltipText: "{name}:\n[bold]{stores} stores[/]" // the place's name and its number of stores
  });

  // Add store bullet: a bubble with the number of stores on it
  pointSeries.bullets.push(function() {
    var container = am5.Container.new(root, {});

    var circle = container.children.push(am5.Circle.new(root, {
      radius: 10 // a starting size; the heat rule sets the real one
    }, circleTemplate));

    container.children.push(am5.Label.new(root, {
      text: "{stores}",  // the number of stores...
      populateText: true // ...filled in from the data item
    }, labelTemplate));

    // Set up drill-down
    circle.events.on("click", function(ev) {

      // Determine what we've clicked on
      var data = ev.target.dataItem.dataContext;

      // No target? An individual store: nothing to drill down to further
      if (!data.target) {
        return;
      }

      // Create actual series if it hasn't been yet created
      if (!regionalSeries[data.target].series) {
        // deeper levels are sized by the stores' count field
        regionalSeries[data.target].series = createSeries("count");
        regionalSeries[data.target].series.data.setAll(data.markerData);
      }

      // Hide current series
      if (currentSeries) {
        currentSeries.hide();
      }

      // Control zoom
      if (data.type == "state") {
        var statePolygon = getPolygon("US-" + data.state);
        polygonSeries.zoomToDataItem(statePolygon.dataItem); // zoom to fit the state
      }
      else if (data.type == "city") {
        // zoom level 64, centered on the city
        chart.zoomToGeoPoint({
          latitude: data.geometry.coordinates[1],
          longitude: data.geometry.coordinates[0]
        }, 64, true);
      }
      zoomOut.show(); // show the zoom-out button

      // Show new target series
      currentSeries = regionalSeries[data.target].series;
      currentSeries.show();
    });

    return am5.Bullet.new(root, {
      sprite: container
    });
  });

  // Add heat rule for circles
  pointSeries.set("heatRules", [{
    target: circleTemplate,
    dataField: "value",
    min: 10, // bubbles from 10px...
    max: 30, // ...to 30px radius
    key: "radius"
  }]);

  return pointSeries;
}

More Maps demos.

More map tools.