Tutorial hero
Lesson icon

Part 3: Advanced Google Maps Integration with Ionic 1.x and Remote Data

Originally published July 30, 2015 Time 25 mins

In Part 1 of this tutorial series we looked at how to set up a basic Google Maps integration within an Ionic application. In Part 2, we took that a little further and looked at how we might load in map markers dynamically using the $http service.

In this tutorial we’re going to complete the transition from basic Google Maps implementation to an advanced and production ready implementation. We’re going to tackle the two major remaining issues with integrating Google Maps into an Ionic application which are:

  1. Dealing with a user who has no Internet connection or loses their Internet connection
  2. Only loading markers that need to be loaded, not loading every single marker that exists at once

To do this we’re going to make some modifications to what we have already done, so if you want to follow along make sure you have completed Part 1 and Part 2 already.

Dealing with Online and Offline States

We’re using the Google Maps JavaScript SDK which needs to be loaded from Googles servers. So what happens when a user tries to access our application when they are not connected to the Internet?

If we haven’t added any mechanism to deal with that scenario, then the app will break. The app will try to access the google object at some point which will not exist because the SDK could not be loaded and it will throw an error.

Alternatively, what if the user did have an Internet connection initially but loses it later? An Internet connection is needed to pull in new data to the map so again, it won’t work.

To deal with this, we’re going to implement functionality within our application that will:

  • Only load the Google Maps SDK if the user is online
  • If the user is not online, wait until they are online and then load the Google Maps SDK
  • If the user was online but goes offline, disable the map with a friendly error message
  • If the user was offline but comes back online, reenable the map and remove the error message.

and it’s going to look something like this:

Google Maps Not Available

There are ways we can detect if the user is online both by using a Cordova plugin, and also by just using simple JavaScript. As well as detecting if they are online, we can also detect when they go online or offline. I’ve talked about how to do this in depth in this tutorial but in short the code looks like this:

// listen for Online event
$rootScope.$on('$cordovaNetwork:online', function (event, networkState) {
  doSomething();
});

// listen for Offline event
$rootScope.$on('$cordovaNetwork:offline', function (event, networkState) {
  doSomething();
});

when using the $cordovaNetwork plugin, or like this:

window.addEventListener(
  'online',
  function (e) {
    doSomething();
  },
  false
);

window.addEventListener(
  'offline',
  function (e) {
    doSomething();
  },
  false
);

when just using plain old JavaScript. So we’re going to use this concept to dynamically load the Google Maps SDK based on when an Internet connection is available, and disable and reenable the map based on the users current connection.

REMOVE the following line from index.html

<script src="http://maps.google.com/maps/api/js?&sensor=true"></script>

Since we want to load the Google Maps SDK based on when a connection is available, we have to remove it from index.html which will try to load it right away.

NOTE: This code is based on Rohde Fischer’s solution to this problem in Sencha Touch.

First we are going to create another factory to give us a consistent way to tell if the user is online or offline (since which method we use depends on whether it is running on a device or not). Before we do that though, let’s install the $cordovaNetwork plugin.

Run the following command to install the $cordovaNetwork plugin

cordova plugin add cordova-plugin-network-information

Create a ConnectivityMonitor factory that looks like this:

.factory('ConnectivityMonitor', function($rootScope, $cordovaNetwork){

  return {
    isOnline: function(){

      if(ionic.Platform.isWebView()){
        return $cordovaNetwork.isOnline();
      } else {
        return navigator.onLine;
      }

    },
    ifOffline: function(){

      if(ionic.Platform.isWebView()){
        return !$cordovaNetwork.isOnline();
      } else {
        return !navigator.onLine;
      }

    }
  }
})

Now we can simply call the isOnline or isOffline functions from this factory and it will return the network status no matter what platform we are running on.

Modify your GoogleMaps factory to reflect the following

.factory('GoogleMaps', function($cordovaGeolocation, $ionicLoading,
$rootScope, $cordovaNetwork, Markers, ConnectivityMonitor){

  var apiKey = false;
  var map = null;

  function initMap(){

    var options = {timeout: 10000, enableHighAccuracy: true};

    $cordovaGeolocation.getCurrentPosition(options)
.then(function(position){

      var latLng = new google.maps.LatLng(position.coords.latitude,
position.coords.longitude);

      var mapOptions = {
        center: latLng,
        zoom: 15,
        mapTypeId: google.maps.MapTypeId.ROADMAP
      };

      map = new google.maps.Map(document.getElementById("map"), mapOptions);

      //Wait until the map is loaded
      google.maps.event.addListenerOnce(map, 'idle', function(){
        loadMarkers();
        enableMap();
      });

    }, function(error){
      console.log("Could not get location");
    });

  }

  function enableMap(){
    $ionicLoading.hide();
  }

  function disableMap(){
    $ionicLoading.show({
      template: 'You must be connected to the Internet to view this map.'
    });
  }

  function loadGoogleMaps(){

    $ionicLoading.show({
      template: 'Loading Google Maps'
    });

    //This function will be called once the SDK has been loaded
    window.mapInit = function(){
      initMap();
    };

    //Create a script element to insert into the page
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.id = "googleMaps";

    //Note the callback function in the URL is the one we created above
    if(apiKey){
      script.src = 'http://maps.google.com/maps/api/js?key=' + apiKey
+ '&sensor=true&callback=mapInit';
    }
    else {
script.src = 'http://maps.google.com/maps/api/js?sensor=true&callback=mapInit';
    }

    document.body.appendChild(script);

  }

  function checkLoaded(){
    if(typeof google == "undefined" || typeof google.maps == "undefined"){
      loadGoogleMaps();
    } else {
      enableMap();
    }
  }

  function loadMarkers(){

      //Get all of the markers from our Markers factory
      Markers.getMarkers().then(function(markers){

        console.log("Markers: ", markers);

        var records = markers.data.result;

        for (var i = 0; i < records.length; i++) {

          var record = records[i];
          var markerPos = new google.maps.LatLng(record.lat, record.lng);

          // Add the markerto the map
          var marker = new google.maps.Marker({
              map: map,
              animation: google.maps.Animation.DROP,
              position: markerPos
          });

          var infoWindowContent = "<h4>" + record.name + "</h4>";

          addInfoWindow(marker, infoWindowContent, record);

        }

      });

  }

  function addInfoWindow(marker, message, record) {

      var infoWindow = new google.maps.InfoWindow({
          content: message
      });

      google.maps.event.addListener(marker, 'click', function () {
          infoWindow.open(map, marker);
      });

  }

  function addConnectivityListeners(){

    if(ionic.Platform.isWebView()){

      // Check if the map is already loaded when the user comes online,
//if not, load it
      $rootScope.$on('$cordovaNetwork:online', function(event, networkState){
        checkLoaded();
      });

      // Disable the map when the user goes offline
      $rootScope.$on('$cordovaNetwork:offline', function(event, networkState){
        disableMap();
      });

    }
    else {

      //Same as above but for when we are not running on a device
      window.addEventListener("online", function(e) {
        checkLoaded();
      }, false);

      window.addEventListener("offline", function(e) {
        disableMap();
      }, false);
    }

  }

  return {
    init: function(key){

      if(typeof key != "undefined"){
        apiKey = key;
      }

      if(typeof google == "undefined" || typeof google.maps == "undefined"){

        console.warn("Google Maps SDK needs to be loaded");

        disableMap();

        if(ConnectivityMonitor.isOnline()){
          loadGoogleMaps();
        }
      }
      else {
        if(ConnectivityMonitor.isOnline()){
          initMap();
          enableMap();
        } else {
          disableMap();
        }
      }

      addConnectivityListeners();

    }
  }

});

There’s a bunch of code that has been added to the factory so I’ve added some comments in the parts that might not be obvious. But essentially we have just created some functions to only load the SDK when the user is online, disable the map if they go offline, and reenable it when they come back online.

Also note that we have injected a few extra services into the Factory:

  • $ionicLoading allows us to display a nice loading mask
  • $rootScope and $cordovaNetwork allow us to monitor the network status
  • ConnectivityMonitor is the factory we just created

We’ve also added the ability to supply an API Key by initlaising the factory like this:

GoogleMaps.init("API KEY GOES HERE");

if an API key is not supplied it will initialise without one. If you try loading your application without an Internet connection now you should see something like this:

No Internet Connection

and if you come back online, even without refreshing the app, the map should start working as normal.

Only Loading On-Screen Markers

If your map only has a few markers, even 50 or a 100 markers then this isn’t really a problem. You could simply load all the markers at once and be done with it.

But loading each marker and adding it to the map does mean a small performance hit. If you’re loading 1000 markers then this performance hit is no longer insignificant. What if you allow users of your application to submit markers and you end up with a total of 500,000 markers? You certainly don’t want to load all of those in at once.

To deal with this, we are going to figure out the area of the map that the user is currently looking at and only load in markers that are contained within that area. The process will look something like this:

  1. Send the current bounds of the map to the server that contains the markers
  2. Only return markers that fall within those bounds
  3. Add those markers to the map

This process will be triggered again whenever the user drags the map, which changes the bounds of the map. The end result is that markers will drop in just as the user is looking at that portion of the map.

NOTE: This code is based on the Ext.ux.MapLoader plugin by SwarmOnline for Sencha Touch.

Modify your GoogleMaps factory to reflect the following:

.factory('GoogleMaps', function($cordovaGeolocation, $ionicLoading,
$rootScope, $cordovaNetwork, Markers, ConnectivityMonitor){

  var markerCache = [];
  var apiKey = false;
  var map = null;

  function initMap(){

    var options = {timeout: 10000, enableHighAccuracy: true};

    $cordovaGeolocation.getCurrentPosition(options)
.then(function(position){

      var latLng = new google.maps.LatLng(position.coords.latitude,
position.coords.longitude);

      var mapOptions = {
        center: latLng,
        zoom: 15,
        mapTypeId: google.maps.MapTypeId.ROADMAP
      };

      map = new google.maps.Map(document.getElementById("map"),
mapOptions);

      //Wait until the map is loaded
      google.maps.event.addListenerOnce(map, 'idle', function(){
        loadMarkers();

        //Reload markers every time the map moves
        google.maps.event.addListener(map, 'dragend', function(){
          console.log("moved!");
          loadMarkers();
        });

        //Reload markers every time the zoom changes
        google.maps.event.addListener(map, 'zoom_changed', function(){
          console.log("zoomed!");
          loadMarkers();
        });

        enableMap();

      });

    }, function(error){
      console.log("Could not get location");
    });

  }

  function enableMap(){
    $ionicLoading.hide();
  }

  function disableMap(){
    $ionicLoading.show({
      template: 'You must be connected to the Internet to view this map.'
    });
  }

  function loadGoogleMaps(){

    $ionicLoading.show({
      template: 'Loading Google Maps'
    });

    //This function will be called once the SDK has been loaded
    window.mapInit = function(){
      initMap();
    };

    //Create a script element to insert into the page
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.id = "googleMaps";

    //Note the callback function in the URL is the one we created above
    if(apiKey){
      script.src = 'http://maps.google.com/maps/api/js?key=' + apiKey
+ '&sensor=true&callback=mapInit';
    }
    else {
script.src = 'http://maps.google.com/maps/api/js?sensor=true&callback=mapInit';
    }

    document.body.appendChild(script);

  }

  function checkLoaded(){
    if(typeof google == "undefined" || typeof google.maps == "undefined"){
      loadGoogleMaps();
    } else {
      enableMap();
    }
  }

  function loadMarkers(){

      var center = map.getCenter();
      var bounds = map.getBounds();
      var zoom = map.getZoom();

      //Convert objects returned by Google to be more readable
      var centerNorm = {
          lat: center.lat(),
          lng: center.lng()
      };

      var boundsNorm = {
          northeast: {
              lat: bounds.getNorthEast().lat(),
              lng: bounds.getNorthEast().lng()
          },
          southwest: {
              lat: bounds.getSouthWest().lat(),
              lng: bounds.getSouthWest().lng()
          }
      };

      var boundingRadius = getBoundingRadius(centerNorm, boundsNorm);

      var params = {
        "centre": centerNorm,
        "bounds": boundsNorm,
        "zoom": zoom,
        "boundingRadius": boundingRadius
      };

      var markers = Markers.getMarkers(params).then(function(markers){
        console.log("Markers: ", markers);
        var records = markers.data.result;

        for (var i = 0; i < records.length; i++) {

          var record = records[i];

          // Check if the marker has already been added
          if (!markerExists(record.lat, record.lng)) {

              var markerPos = new google.maps.LatLng(record.lng, record.lat);
              // add the marker
              var marker = new google.maps.Marker({
                  map: map,
                  animation: google.maps.Animation.DROP,
                  position: markerPos
              });

// Add the marker to the markerCache so we know not to add it again later
              var markerData = {
                lat: record.lat,
                lng: record.lng,
                marker: marker
              };

              markerCache.push(markerData);

              var infoWindowContent = "<h4>" + record.name + "</h4>";

              addInfoWindow(marker, infoWindowContent, record);
          }

        }

      });
  }

  function markerExists(lat, lng){
      var exists = false;
      var cache = markerCache;
      for(var i = 0; i < cache.length; i++){
        if(cache[i].lat === lat && cache[i].lng === lng){
          exists = true;
        }
      }

      return exists;
  }

  function getBoundingRadius(center, bounds){
    return getDistanceBetweenPoints(center, bounds.northeast, 'miles');
  }

  function getDistanceBetweenPoints(pos1, pos2, units){

    var earthRadius = {
        miles: 3958.8,
        km: 6371
    };

    var R = earthRadius[units || 'miles'];
    var lat1 = pos1.lat;
    var lon1 = pos1.lng;
    var lat2 = pos2.lat;
    var lon2 = pos2.lng;

    var dLat = toRad((lat2 - lat1));
    var dLon = toRad((lon2 - lon1));
    var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
    Math.sin(dLon / 2) *
    Math.sin(dLon / 2);
    var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    var d = R * c;

    return d;

  }

  function toRad(x){
      return x * Math.PI / 180;
  }

  function addInfoWindow(marker, message, record) {

      var infoWindow = new google.maps.InfoWindow({
          content: message
      });

      google.maps.event.addListener(marker, 'click', function () {
          infoWindow.open(map, marker);
      });

  }

  function addConnectivityListeners(){

    if(ionic.Platform.isWebView()){

      // Check if the map is already loaded when the user comes online,
//if not, load it
      $rootScope.$on('$cordovaNetwork:online', function(event, networkState){
        checkLoaded();
      });

      // Disable the map when the user goes offline
      $rootScope.$on('$cordovaNetwork:offline', function(event, networkState){
        disableMap();
      });

    }
    else {

      //Same as above but for when we are not running on a device
      window.addEventListener("online", function(e) {
        checkLoaded();
      }, false);

      window.addEventListener("offline", function(e) {
        disableMap();
      }, false);
    }

  }

  return {
    init: function(key){

      if(typeof key != "undefined"){
        apiKey = key;
      }

      if(typeof google == "undefined" || typeof google.maps == "undefined"){

        console.warn("Google Maps SDK needs to be loaded");

        disableMap();

        if(ConnectivityMonitor.isOnline()){
          loadGoogleMaps();
        }
      }
      else {
        if(ConnectivityMonitor.isOnline()){
          initMap();
          enableMap();
        } else {
          disableMap();
        }
      }

      addConnectivityListeners();

    }
  }

});

Again, there’s a lot going on here but the key parts are:

  • We supply the bounds of the map as parameters when loadining markers (which we will make use of shortly)
  • We are keeping track of markers we have already added with markerCache to make sure we don’t add the same ones twice
  • We listen for when the map changes its bounds (when the user drags or zooms) and then load more markers by supplying the new bounds information
  • We’ve added some functions like getDistanceBetweenPoints which are just using standard formulas for calculating distances between points (it’s the Haversine Formula for those interested). We do this so we can calculate the distance for which we want to load markers: if the map was zoomed in the distance from the center to the corner might be 1km, but zoomed out it could be 100km
  • We are recreating our own boundsNorm, centerNorm etc. objects because the object supplied by Google has nonsensical names.

Since we are now supplying a params object to the Markers factory, we need to alter the Markers factory a little bit

Modify your Markers factory to reflect the following:

.factory('Markers', function($http) {

  var markers = [];

  return {
    getMarkers: function(params){

      return $http.get("http://example.com/markers.php",{params:params}).then(function(response){
          markers = response;
          return markers;
      });

    }
  }

})

Finally, we need to modify the server side code to take the area of the map we are looking at into account. Again, my example is in PHP but you can implement this with whatever you would like.

Modify your markers.php file or similar to reflect the following

<?php

    //Create a connection to the database
    $mysqli = new mysqli("localhost", "username", "password", "database");

    //The default result to be output to the browser
    $result = "{'success':false}";

    //Grab the extra params
    $centre = json_decode(stripslashes($_GET["centre"]), true);
    $bounds = json_decode(stripslashes($_GET["bounds"]), true);
    $boundingRadius = $_GET["boundingRadius"];

    //Select everything from the table containing the marker informaton
    $query = sprintf("SELECT *, ( 3959 * acos( cos( radians('%s') ) * cos( radians( lat ) )
* cos( radians( lng ) - radians('%s') ) + sin( radians('%s') )
* sin( radians( lat ) ) ) ) AS distance FROM markers ",
      $mysqli->real_escape_string($centre['lat']),
      $mysqli->real_escape_string($centre['lng']),
      $mysqli->real_escape_string($centre['lat']));

    //Restrict query to those within bounding radius
    $maxDistance = $mysqli->real_escape_string($boundingRadius);

    $query .= " HAVING distance < '".$maxDistance."'";
    $query .= " ORDER BY distance";
    $query .= " LIMIT 300";

    //Run the query
    $dbresult = $mysqli->query($query);

    //Build an array of markers from the result set
    $markers = array();

    while($row = $dbresult->fetch_array(MYSQLI_ASSOC)){

        $markers[] = array(
            'id' => $row['id'],
            'name' => $row['name'],
            'lat' => $row['lat'],
            'lng' => $row['lng']
        );
    }

    //If the query was executed successfully, create a JSON string containing the marker information
    if($dbresult){
        $result = "{'success':true, 'markers':" . json_encode($markers) . "}";
    }
    else
    {
        $result = "{'success':false}";
    }

    //Set these headers to avoid any issues with cross origin resource sharing issues
    header('Access-Control-Allow-Origin: *');
    header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type,x-prototype-version,x-requested-with');

    //Output the result to the browser so that our Ionic application can see the data
    echo($result);

?>

Now in the code above we’re calculating the distance of each marker from the center of the map, and only returning those that have a distance that is less than the bounding radius of the map, which is the distance from the center of the map to the corner. Just in case there is still a lot of markers being returned, we set a hard limit of 300 markers per load.

If you load up your application now you should see the markers appear only as you look at a specific location. That brings us to the end of this tutorial series, but if there’s anything else you think would be useful to cover leave it in the comments below.

Learn to build modern Angular apps with my course