Asset trackers are an often required component for any company that has trucks, buses, or other assets that are on the move. In this codelab you will build the front end components that are responsible for driving a Transport Tracker solution, based on the I/O Bus Tracker. We will show you how to use the Google Maps JavaScript API, along with Firebase Realtime Database, to build a system that can be used to track assets in near real time.

What you'll build

In this codelab, you'll build a web application that:

  • Is dynamic - you'll use Firebase Realtime Database to source the dynamic data from the backend and to move markers on the map in response to realtime transactions.
  • Uses a customised map, styled to suit a particular purpose and brand.
  • Displays a realtime time table for buses or other types of transport.

What you'll learn

What you'll need

This codelab focuses on the Google Maps JavaScript API. Non-relevant concepts and code blocks are glossed over and are provided for you to simply copy and paste.

Environment setup

This codelab is a follow-on from the Transport Tracker Backend codelab. You'll need the Google Cloud Platform project and the Firebase Realtime Database that you created during that codelab. If you haven't done the codelab, please take the time to do it now.

Sign in to Google Cloud Platform console (console.cloud.google.com) and open the project that you created during the Transport Tracker Backend codelab.

Activate Cloud Shell: on the Cloud platform console, click the button on the top right-hand side:

Next, you need to enable the Google Maps JavaScript API for your Google APIs project. Go to the Google API Console, open the project that you created in step 4 of the Transport Tracker Backend codelab, and check the list of enabled APIs on the console Dashboard. If the Google Maps JavaScript API is not there, click Enable API, and search for the Maps JavaScript API.

Enable the API:

Starting development with a map

The inevitable starting point for any web application based on geographical information, like say tracking the locations of buses, is a map. The first thing we are going to do is put a map on a web page, showing the location of Google I/O. For this codelab we'll use Firebase Hosting to house the static contents of this web app, and we'll source the dynamic data for this app from the Transport Tracker Backend codelab.

In the toolbar of the Cloud Shell, click on the file icon to open a menu, and then select Launch code editor to open the code editor in a new tab.

Create a new transport-tracker-map directory for your application in the code editor, by opening the File menu, and selecting New > Folder.

Name the new folder transport-tracker-map:

Next you'll create a small Firebase Hosting web app to make sure everything's working. In the editor, create a new file under the transport-tracker-map directory called package.json, and put the following content in it:

package.json

{
  "name": "transport-tracker-map",
  "version": "1.0.0",
  "description": "A map showing the real time location of buses.",
  "scripts": {
    "prettier": "prettier --write --single-quote --bracket-spacing=false public/js/*.js",
    "firebase-init": "firebase init",
    "firebase-login": "firebase login --no-localhost",
    "firebase-deploy": "firebase deploy"
  },
  "author": "Brett Morgan",
  "license": "Apache-2.0",
  "devDependencies": {
    "firebase-tools": "^3.6.1",
    "prettier": "^1.2.2"
  }
}

This file declares the project's dependency on firebase-tools, the npm version of Firebase CLI, along with prettier, an opinionated JavaScript formatter. The scripts section of this file also includes the Firebase commands we'll need to run, to create a project, get an authentication token, and then deploy our web app to Firebase Hosting. Let's get started with firebase init.

On the Google Cloud Shell command line, change the current working directory to the transport-tracker-map directory created above:

cd ~/transport-tracker-map/

Next, run npm install to pull down all of the dependencies declared in the package.json file:

npm install

We will be using Firebase in this tutorial, so we need to log in. You can do that by running the following command:

npm run firebase-login

This will ask you whether you want to allow Firebase to collect anonymous CLI usage information (please say yes, it allows us to build tools that more appropriately reflect actual usage), and then it will give you a URL to cut'n'paste into your web browser.

Highlight the URL in Google Cloud Shell, then copy it into your copy buffer and paste it into a new tab in your Chrome browser. This will take you to a Google login page that will ask you to choose which account to authorize Firebase CLI to operate as. After you have enabled Firebase CLI to authenticate as the selected user, you will be given a token that you can cut'n'paste back into the Google Cloud Shell session, to authenticate the Firebase CLI. You should see a line stating the the tool is successfully logged in as your user. If not, repeat the above process until successful.

Now, we can create a default Firebase Hosting web app.

npm run firebase-init

The first question you will be asked is which features of Firebase you will be using in this project.

Use the arrow keys to move up and down the list. Use the spacebar to deselect the Functions option, but leave Database and Hosting selected. Press Enter when you're ready.

Next you will be asked to select a default Firebase project for this directory. Scroll through the list of available Firebase projects until you find the one you are populating with the Transport Tracker Backend Node.js app. It is important that you select the same Firebase project for both the backend and the map, so that they communicate through the same Firebase Realtime database. Press Enter after selecting the right project.

Next you will be asked for the name of the database rules JSON file. Go with the default of database.rules.json.

Next you will be asked which directory you want to use to contain the files that Firebase Hosting will serve. Go with the default of public.

Then you are asked whether to rewrite all URLs to /index.html. Go with the default answer of No.

After this the Firebase CLI tool will create a public directory with an index.html and a 404.html, along with a database.rules.json file. You should be able to inspect these files in the editor, but you may have to restart it first before the public directory will be visible. You can publish these files as is to Firebase Hosting to prove that everything is working:

npm run firebase-deploy

The last line of output from the firebase command will be a URL for your newly created web application. Please feel free to open the URL in a new Chrome tab and marvel at the beautiful pixels.

Before going any further, we will update the database rules to allow read access to anyone, while retaining credentials to write to the database. Open the database.rules.json file in the editor, and change the contents to match the following.

database.rules.json

{
  "rules": {
    ".read": true,
    ".write": "auth.uid !== null"
  }
}

Now, run the deploy step from above again, this time to make sure the database content will be available in the next step.

npm run firebase-deploy

Now that we have a working deployment, let's change the content of the ~/transport-tracker-map/public/index.html file to the following:

index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>Transport Tracker</title>
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
  <link rel="stylesheet" type="text/css" href="css/main.css">
</head>

<body>

  <div id="map" class="map"></div>

  <script defer src="js/index.js"></script>
  <!-- Change out the following API_KEY for your Maps API Key -->
  <script defer src="https://maps.googleapis.com/maps/api/js?key=API_KEY&callback=initMap"></script>

</body>
</html>

In the script element at the end of the HTML file, replace the value API_KEY with your Maps API key, which you obtained in the Transport Tracker Backend codelab. (You can find the Maps API key in your ~/transport-tracker-server/tracker-configuration.json file.)

The above index.html file has two declared dependencies that we need to fulfill, one for a CSS style sheet, and the other for a JavaScript file that will set up the map. The JavaScript file will contain an initMap function that the Google Maps JavaScript API will call once it has loaded.

Create a css directory under ~/transport-tracker-map/public. In the css directory, create a new file called main.css and put the following CSS code in it:

main.css

html,
body {
  font-family: 'Roboto', 'Helvetica', sans-serif;
  margin: 0;
  padding: 0;
}

body {
  overflow: hidden;
}

#map {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: -10;
}

Create a js directory under ~/transport-tracker-map/public. In the js directory, create a file called index.js, and put the following content in it:

index.js

/* eslint-disable no-unused-vars, no-shadow-global */
/* globals google */

const mapStyle = [
  {
    elementType: 'geometry',
    stylers: [
      {
        color: '#eceff1'
      }
    ]
  },
  {
    elementType: 'labels',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'administrative',
    elementType: 'labels',
    stylers: [
      {
        visibility: 'on'
      }
    ]
  },
  {
    featureType: 'road',
    elementType: 'geometry',
    stylers: [
      {
        color: '#cfd8dc'
      }
    ]
  },
  {
    featureType: 'road',
    elementType: 'geometry.stroke',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'road.local',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'water',
    stylers: [
      {
        color: '#b0bec5'
      }
    ]
  }
];

function initMap() {
  const map = new google.maps.Map(document.getElementById('map'), {
    center: {lat:37.4268042, lng: -122.0828066},
    zoom: 13,
    styles: mapStyle
  });
}

The above code contains two top level elements. The first is a map style in JSON format. The second is a function declaration for initMap, which will be called by Google Maps JavaScript API. You can fashion your own map style with the Google Maps APIs Styling Wizard.

Now that all of the content is in place, re-run the Firebase Hosting deploy step:

npm run firebase-deploy

This will update the content in Firebase Hosting. Reopen the hosting URL and you should see a styled map, centered on South Bay.

In the next step we will connect this map with the Firebase Realtime Database, and start displaying the real time location of buses. Exciting!

Moving a map with Firebase

In this step we will connect the map web app created in the previous step with the Firebase Realtime Database that the Transport Tracker Backend is publishing into. Using Firebase Hosting makes this easier, because we can include the Firebase project credentials into the hosted app automatically. To see how, let's make some changes to the three files we created in the last step.

First, open up the index.html file again, and change its content to the following:

index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>Transport Tracker</title>
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
  <link rel="stylesheet" type="text/css" href="css/main.css">
</head>

<body>
  <div class="top-bar">
    <span class="io-bus-tracker">Transport Tracker</span>
    <span id="display-time" class="display-time">9:00 AM, May 18th</span>
    <span class="google-maps-apis"></span>
  </div>
  <div id="map" class="map"></div>
  <div id="page-marker" class="page-marker">
    <svg>
      <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
        <circle id="page-marker-panel-0" cx="13" cy="13" r="12" stroke="#445A65" stroke-width="2" fill="none" />
        <circle id="page-marker-panel-1" cx="61" cy="13" r="12" stroke="#445A65" stroke-width="2" fill="none" />
        <circle id="page-marker-panel-2" cx="109" cy="13" r="12" stroke="#445A65" stroke-width="2" fill="none" />
      </g>
    </svg>
  </div>

  <script defer src="/__/firebase/3.8.0/firebase-app.js"></script>
  <script defer src="/__/firebase/3.8.0/firebase-database.js"></script>
  <script defer src="/__/firebase/init.js"></script>
  <script defer src="js/index.js"></script>
  <!-- Change out the following API_KEY for your Maps API Key -->
  <script defer src="https://maps.googleapis.com/maps/api/js?key=API_KEY&callback=initMap"></script>

</body>
</html>

Remember to replace the value API_KEY with your own Maps API key.

We have made a couple of additions. Firstly, we have added a div above the map, with a class of io-bus-tracker, that will show the time and date. Currently it has boilerplate text, but we'll populate it from Firebase soon enough. Next we have added a div under the map div, containing some inline SVG. This SVG represents the pager notification at the bottom of the map, indicating which page of results is being shown. We will modify this SVG in response to changes in the Firebase Realtime Database. Finally, we've included in the Firebase JavaScript libraries to allow us to use the Firebase Realtime Database.

As described above, we have added two div elements to our HTML, so we need to appropriately style them. Open up the css/main.css file again, and change its contents to match the following:

main.css

html,
body {
  font-family: 'Roboto', 'Helvetica', sans-serif;
  margin: 0;
  padding: 0;
}

body {
  overflow: hidden;
}

.map {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: -10;
}

.page-marker {
  position: absolute;
  width: 144px;
  height: 24px;
  left: calc(50vw - 72px);
  bottom: 36px;
  transition-duration: 1s;
}

.page-marker .selected {
  fill: #445A65;
}


/*
 * Top bar
 */

.top-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 64px;
  background-color: #445A65;
  z-index: 20;
}

.top-bar .io-bus-tracker {
  position: absolute;
  top: 24px;
  left: 36px;
  font-size: 18px;
  color: rgba(255, 255, 255, 0.97);
}

.top-bar .google-maps-apis {
  position: absolute;
  top: 24px;
  right: 36px;
  width: 175px;
  height: 24px;
  background-image: url(/images/logo_lockup_maps_apis_light.png);
}

.top-bar .display-time {
  position: absolute;
  top: 24px;
  width: 160px;
  left: calc(50vw - 80px);
  font-size: 18px;
  color: rgba(255, 255, 255, 0.97);
}

Next, open up the js/index.js file and change its content to match the following:

index.js

/* eslint-disable no-unused-vars, no-shadow-global */
/* globals google firebase */

const mapStyle = [
  {
    elementType: 'geometry',
    stylers: [
      {
        color: '#eceff1'
      }
    ]
  },
  {
    elementType: 'labels',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'administrative',
    elementType: 'labels',
    stylers: [
      {
        visibility: 'on'
      }
    ]
  },
  {
    featureType: 'road',
    elementType: 'geometry',
    stylers: [
      {
        color: '#cfd8dc'
      }
    ]
  },
  {
    featureType: 'road',
    elementType: 'geometry.stroke',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'road.local',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'water',
    stylers: [
      {
        color: '#b0bec5'
      }
    ]
  }
];

function geocodeAddress(address, map, icon, title) {
  const geocoder = new google.maps.Geocoder();
  geocoder.geocode(
    {
      address: address
    },
    (results, status) => {
      if (status === 'OK') {
        const marker = new google.maps.Marker({
          map: map,
          position: results[0].geometry.location,
          icon: icon,
          title: title
        });
      } else {
        console.log(
          'Geocode was not successful for the following reason: ' + status
        );
      }
    }
  );
}

class HotelMarkerManager {
  constructor(map) {
    this.map = map;
    this.hotelMarkers = [];
  }

  add(location, icon, title) {
    const marker = new google.maps.Marker({
      position: location,
      map: this.map,
      icon: icon,
      title: title
    });
    this.hotelMarkers.push(marker);
  }

  clear() {
    this.hotelMarkers.forEach(marker => {
      marker.setMap(null);
    });
    this.hotelMarkers.length = 0;
  }

  update(markers) {
    this.clear();
    markers.forEach(marker => {
      this.add(
        {
          lat: marker.lat,
          lng: marker.lng
        },
        marker.iconPath,
        marker.name
      );
    });
  }
}

class BusMarkerManager {
  constructor(map) {
    this.map = map;
    this.busLocationMarkers = {};
  }

  update(val) {
    for (let key in this.busLocationMarkers) {
      if (val === null || !(key in val)) {
        const marker = this.busLocationMarkers[key];
        marker.setMap(null);
        delete this.busLocationMarkers[key];
      }
    }

    for (let key in val) {
      const bus = val[key];

      if (key in this.busLocationMarkers) {
        const marker = this.busLocationMarkers[key];
        marker.setPosition({
          lat: bus.lat,
          lng: bus.lng
        });
      } else {
        const url = this.colorToBusMarker(bus.route_color);
        const marker = new google.maps.Marker({
          position: {
            lat: bus.lat,
            lng: bus.lng
          },
          map: this.map,
          icon: {
            url,
            anchor: new google.maps.Point(18, 18)
          },
          title: bus.route_name
        });
        this.busLocationMarkers[key] = marker;
      }
    }
  }

  colorToBusMarker(color) {
    switch (color) {
      case '86D1D8':
        return '/images/dashboard/busmarker_blue.png';
      case '445963':
        return '/images/dashboard/busmarker_gray.png';
      case '7BB241':
        return '/images/dashboard/busmarker_green.png';
      case '5D6ABF':
        return '/images/dashboard/busmarker_indigo.png';
      case 'A8D84E':
        return '/images/dashboard/busmarker_lime.png';
      case 'FCBBCB':
        return '/images/dashboard/busmarker_pink.png';
      case 'FF5151':
        return '/images/dashboard/busmarker_red.png';
      case '25C5D9':
        return '/images/dashboard/busmarker_sf1.png';
      case 'FF4081':
        return '/images/dashboard/busmarker_sf2.png';
      case 'FFC927':
        return '/images/dashboard/busmarker_yellow.png';
    }
  }
}

function initMap() {
  const map = new google.maps.Map(document.getElementById('map'), {
    disableDefaultUI: true,
    styles: mapStyle
  });

  // Put I/O on the map
  geocodeAddress(
    '1 Amphitheatre Pkwy, Mountain View, CA 94043',
    map,
    '/images/dashboard/logo_io_64.png',
    'Google I/O'
  );

  const busMarkerManager = new BusMarkerManager(map);
  const hotelMarkerManager = new HotelMarkerManager(map);
  const displayTimeElement = document.querySelector('#display-time');
  const pageMarkerPanelElts = [
    document.querySelector('#page-marker-panel-0'),
    document.querySelector('#page-marker-panel-1'),
    document.querySelector('#page-marker-panel-2')
  ];
  const db = firebase.database();

  db.ref('current-time').on('value', snapshot => {
    displayTimeElement.textContent = snapshot.val().display;
  });

  db.ref('map').on('value', snapshot => {
    const val = snapshot.val();
    map.fitBounds({
      east: val.northEastLng,
      north: val.northEastLat,
      south: val.southWestLat,
      west: val.southWestLng
    });

    hotelMarkerManager.update(val.markers);

    pageMarkerPanelElts.forEach(elt => {
      elt.classList.remove('selected');
    });
    pageMarkerPanelElts[val.panel].classList.add('selected');
  });

  db.ref('bus-locations').on('value', snapshot => {
    busMarkerManager.update(snapshot.val());
  });
}

There is a fair amount of new code here, but if we work through it slowly, it should start to make sense. The first thing to realize is that we have three separate sets of markers on this map. The first, denoting the location of Google I/O, is a fixed marker that is created at map creation time, and then never changed again. We place this marker using the Google Maps JavaScript API Geocoding Service in the geocodeAddress function. The next set of markers represent the hotels that the I/O buses pick up from and drop off to. We manage these using a class called HotelMarkerManager. The markers displayed change for each displayed panel. The final set of markers represent the real time location of our roving buses, and they are managed by the BusMarkerManager.

In the significantly beefed up initMap function declared at the bottom of the index.js file, we first geocode the location of Google I/O, then we use document.querySelector to get references to parts of the HTML page's DOM, and then we wire up callbacks for changes on various parts of the Firebase Realtime Database. The first callback for the current-time path is used to populate the time and date in the top bar, the map path is used to control the location of the map and display appropriate hotels, and the bus-locations path is used to plot out the real time locations of the buses.

Before we publish this new version of our app, there is something very important missing. We need images for each of these markers. You can download these images as a zip file from GitHub. In your Google Cloud Shell, change directory into the ~/transport-tracker-map/public directory, and run the following command.

wget https://github.com/googlecodelabs/transport-tracker-codelab/raw/master/map/images.zip

Next we need to unzip the file you just downloaded into the public directory alongside the js and css directories. In the same ~/transport-tracker-map/public directory as above, run the following command.

unzip images.zip

You should now have a newly minted images directory alongside your js and css directories.

Now you can re-deploy your application and look at the resulting map.

npm run firebase-deploy

You should see a map of the San Francisco Bay area, with a few markers showing the locations of buses, hotels, and Google I/O, something like this:

In the next step, we will render cards that show the upcoming departures. In other words, time to get time tabling!

Showing all the cards

Now that we have a map with markers for buses, hotels and Google I/O itself, it's time to overlay the map with the time table cards. This step is the one with the most code, because we are dynamically generating HTML for each of the cards, and inserting them into the DOM. While at first glance the approach we've taken of wholesale replacing the entire card set at each update may appear to be a sub-optimal approach, it turns out web browsers are highly optimised for parsing HTML.

Following the tradition of the last couple of steps, we will start by again making one key addition to the index.html file. This additional div will be where we inject the HTML for the time table cards.

index.html

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>Transport Tracker</title>
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
  <link rel="stylesheet" type="text/css" href="css/main.css">
</head>

<body>
  <div class="top-bar">
    <span class="io-bus-tracker">Transport Tracker</span>
    <span id="display-time" class="display-time">9:00 AM, May 18th</span>
    <span class="google-maps-apis"></span>
  </div>
  <div id="cards" class="cards"></div>
  <div id="map" class="map"></div>
  <div id="page-marker" class="page-marker">
    <svg>
      <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
        <circle id="page-marker-panel-0" cx="13" cy="13" r="12" stroke="#445A65" stroke-width="2" fill="none" />
        <circle id="page-marker-panel-1" cx="61" cy="13" r="12" stroke="#445A65" stroke-width="2" fill="none" />
        <circle id="page-marker-panel-2" cx="109" cy="13" r="12" stroke="#445A65" stroke-width="2" fill="none" />
      </g>
    </svg>
  </div>

  <script defer src="/__/firebase/3.8.0/firebase-app.js"></script>
  <script defer src="/__/firebase/3.8.0/firebase-database.js"></script>
  <script defer src="/__/firebase/init.js"></script>
  <script defer src="js/index.js"></script>
  <!-- Change out the following API_KEY for your Maps API Key -->
  <script defer src="https://maps.googleapis.com/maps/api/js?key=API_KEY&callback=initMap"></script>

</body>
</html>

Remember to replace the value API_KEY with your own Maps API key.

Next, we add all the required CSS declarations to style our timetable cards. There is a lot of code here, taking advantage of CSS Flexbox. This important standard is widely available, and makes a massive difference to how easily a variety of layouts can be achieved in HTML. Well worth the time to learn this technology.

main.css

html,
body {
  font-family: 'Roboto', 'Helvetica', sans-serif;
  margin: 0;
  padding: 0;
}

body {
  overflow: hidden;
}

.map {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: -10;
}

.page-marker {
  position: absolute;
  width: 144px;
  height: 24px;
  left: calc(50vw - 72px);
  bottom: 36px;
  transition-duration: 1s;
}

.page-marker .selected {
  fill: #445A65;
}


/*
 * Top bar
 */

.top-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 64px;
  background-color: #445A65;
  z-index: 20;
}

.top-bar .io-bus-tracker {
  position: absolute;
  top: 24px;
  left: 36px;
  font-size: 18px;
  color: rgba(255, 255, 255, 0.97);
}

.top-bar .google-maps-apis {
  position: absolute;
  top: 24px;
  right: 36px;
  width: 175px;
  height: 24px;
  background-image: url(/images/logo_lockup_maps_apis_light.png);
}

.top-bar .display-time {
  position: absolute;
  top: 24px;
  width: 160px;
  left: calc(50vw - 80px);
  font-size: 18px;
  color: rgba(255, 255, 255, 0.97);
}

/*
 * Card definitions
 */

.cards {
  display: flex;
  flex-direction: column;
  transition-duration: 1s;
  position: absolute;
  transform: translateZ(0px);
  z-index: 10;
}

.panel {
  padding-top: 64px;
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

.side {
  display: flex;
  flex-direction: column;
}

.card {
  width: 500px;
  background-color: #ffffff;
  box-shadow: 0px 5px 10px #888888;
  margin-top: 72px;
  margin-left: 72px;
  margin-right: 72px;
}

.header {
  height: 104px;
  position: relative;
}

.body {
  position: relative;
  display: flex;
  flex-direction: row;
}

.bus_logo {
  width: 64px;
  height: 64px;
  position: absolute;
  top: 24px;
  left: 16px;
}

.direction {
  font-size: 16px;
  position: absolute;
  left: 96px;
  top: 19px;
}

.dark .direction {
  color: rgba(255, 255, 255, 0.7);
}

.light .direction {
  color: rgba(0, 0, 0, 0.5);
}

.headsign {
  font-size: 28px;
  font-weight: 500;
  position: absolute;
  left: 96px;
  top: 42px;
}

.dark .headsign {
  color: rgba(255, 255, 255, 0.97);
}

.light .headsign {
  color: rgba(0, 0, 0, 0.9);
}

.leaving-in-label {
  font-size: 16px;
  position: absolute;
  left: 384px;
  top: 19px;
}

.dark .leaving-in-label {
  color: rgba(255, 255, 255, 0.7);
}

.light .leaving-in-label {
  color: rgba(0, 0, 0, 0.5);
}

.leaving-in {
  font-size: 28px;
  font-weight: 500;
  position: absolute;
  left: 384px;
  top: 42px;
}

.dark .leaving-in {
  color: rgba(255, 255, 255, 0.97);
}

.light .leaving-in {
  color: rgba(0, 0, 0, 0.9);
}

.body .next-in-label {
  font-size: 16px;
  position: absolute;
  left: 384px;
  bottom: 64px;
  color: rgba(0, 0, 0, 0.5);
}

.body .next-in {
  font-size: 28px;
  font-weight: 400;
  position: absolute;
  left: 384px;
  bottom: 24px;
  color: rgba(0, 0, 0, 0.6);
}

.stop-times {
  width: 80px;
  margin-right: 16px;
  margin-top: 24px;
  margin-bottom: 24px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
}

.stop-time {
  font-size: 18px;
  color: rgba(0, 0, 0, 0.6);
}

.stop-guide {
  width: 16px;
  margin-top: 24px;
  margin-bottom: 24px;
}

.stop-names {
  margin-left: 16px;
  margin-top: 24px;
  margin-bottom: 24px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-start;
}

.stop-name {
  font-size: 18px;
  color: rgba(0, 0, 0, 0.9);
}

Lastly, we need to update our index.js file with the capability to render the time table information. Again, there is a lot of code here, but there are a couple of gems in there. One that I'd like to call out is that the bus icons on the cards are inline SVG that is recolored to the route color using simple ES6 string templates. No more messing about in an image editor just to change the color of an icon!

index.js

/* eslint-disable no-unused-vars, no-shadow-global */
/* globals google firebase */

const mapStyle = [
  {
    elementType: 'geometry',
    stylers: [
      {
        color: '#eceff1'
      }
    ]
  },
  {
    elementType: 'labels',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'administrative',
    elementType: 'labels',
    stylers: [
      {
        visibility: 'on'
      }
    ]
  },
  {
    featureType: 'road',
    elementType: 'geometry',
    stylers: [
      {
        color: '#cfd8dc'
      }
    ]
  },
  {
    featureType: 'road',
    elementType: 'geometry.stroke',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'road.local',
    stylers: [
      {
        visibility: 'off'
      }
    ]
  },
  {
    featureType: 'water',
    stylers: [
      {
        color: '#b0bec5'
      }
    ]
  }
];

function geocodeAddress(address, map, icon, title) {
  const geocoder = new google.maps.Geocoder();
  geocoder.geocode(
    {
      address: address
    },
    (results, status) => {
      if (status === 'OK') {
        const marker = new google.maps.Marker({
          map: map,
          position: results[0].geometry.location,
          icon: icon,
          title: title
        });
      } else {
        console.log(
          'Geocode was not successful for the following reason: ' + status
        );
      }
    }
  );
}

class HotelMarkerManager {
  constructor(map) {
    this.map = map;
    this.hotelMarkers = [];
  }

  add(location, icon, title) {
    const marker = new google.maps.Marker({
      position: location,
      map: this.map,
      icon: icon,
      title: title
    });
    this.hotelMarkers.push(marker);
  }

  clear() {
    this.hotelMarkers.forEach(marker => {
      marker.setMap(null);
    });
    this.hotelMarkers.length = 0;
  }

  update(markers) {
    this.clear();
    markers.forEach(marker => {
      this.add(
        {
          lat: marker.lat,
          lng: marker.lng
        },
        marker.iconPath,
        marker.name
      );
    });
  }
}

class BusMarkerManager {
  constructor(map) {
    this.map = map;
    this.busLocationMarkers = {};
  }

  update(val) {
    for (let key in this.busLocationMarkers) {
      if (val === null || !(key in val)) {
        const marker = this.busLocationMarkers[key];
        marker.setMap(null);
        delete this.busLocationMarkers[key];
      }
    }

    for (let key in val) {
      const bus = val[key];

      if (key in this.busLocationMarkers) {
        const marker = this.busLocationMarkers[key];
        marker.setPosition({
          lat: bus.lat,
          lng: bus.lng
        });
      } else {
        const url = this.colorToBusMarker(bus.route_color);
        const marker = new google.maps.Marker({
          position: {
            lat: bus.lat,
            lng: bus.lng
          },
          map: this.map,
          icon: {
            url,
            anchor: new google.maps.Point(18, 18)
          },
          title: bus.route_name
        });
        this.busLocationMarkers[key] = marker;
      }
    }
  }

  colorToBusMarker(color) {
    switch (color) {
      case '86D1D8':
        return '/images/dashboard/busmarker_blue.png';
      case '445963':
        return '/images/dashboard/busmarker_gray.png';
      case '7BB241':
        return '/images/dashboard/busmarker_green.png';
      case '5D6ABF':
        return '/images/dashboard/busmarker_indigo.png';
      case 'A8D84E':
        return '/images/dashboard/busmarker_lime.png';
      case 'FCBBCB':
        return '/images/dashboard/busmarker_pink.png';
      case 'FF5151':
        return '/images/dashboard/busmarker_red.png';
      case '25C5D9':
        return '/images/dashboard/busmarker_sf1.png';
      case 'FF4081':
        return '/images/dashboard/busmarker_sf2.png';
      case 'FFC927':
        return '/images/dashboard/busmarker_yellow.png';
    }
  }
}

class Card {
  constructor(data) {
    this.data = data;
  }

  get direction() {
    // Stop ID #0 is Google I/O
    if (this.data.next_trip.stop_info[0].stop_id === 0) {
      return 'To';
    }
    return 'From';
  }

  get headsign() {
    switch (this.data.route.route_id) {
      case 0:
        return 'Palo Alto';
      case 1:
        return 'Mtn View & Cupertino';
      case 2:
        return 'Sunnyvale';
      case 3:
        return 'Cupertino';
      case 4:
      case 5:
        return 'Santa Clara';
      case 6:
        return 'Sunnyvale, San Jose';
      case 7:
        return 'Mtn View Caltrain';
      case 8:
        return 'Hyatt Regency, SF';
      case 9:
        return 'Westin St. Francis, SF';
      case 10:
        return 'Millbrae BART';
      case 11:
        return 'San Francisco (SFO)';
      case 12:
        return "San Jose Int'l (SJC)";
      default:
        return 'Unknown';
    }
  }

  get isDark() {
    // Dark background for white text
    return this.data.route.route_text_color === 'FFFFFF';
  }

  get color() {
    return `#${this.data.route.route_color}`;
  }

  get cardHeight() {
    return 104 + this.bodyHeight;
  }

  get bodyHeight() {
    return this.data.next_trip.stop_info.length * 40 + 24;
  }

  get render() {
    return `
    <div 
      class="card ${this.isDark ? 'dark' : 'light'}"
      style="height: ${this.cardHeight}px"
      >
      <div class="header" style="background-color: ${this.color}">
        <div class="bus_logo">
          <svg width="64px" height="64px" viewBox="0 0 64 64">
            <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
              <circle fill="#FFFFFF" cx="32" cy="32" r="32"></circle>
              <path
                d="M20.7894737,31.0526316 L43.5263158,31.0526316 L43.5263158,21.5789474 L20.7894737,21.5789474 L20.7894737,31.0526316 Z M40.6842105,42.4210526 C39.1115789,42.4210526 37.8421053,41.1515789 37.8421053,39.5789474 C37.8421053,38.0063158 39.1115789,36.7368421 40.6842105,36.7368421 C42.2568421,36.7368421 43.5263158,38.0063158 43.5263158,39.5789474 C43.5263158,41.1515789 42.2568421,42.4210526 40.6842105,42.4210526 L40.6842105,42.4210526 Z M23.6315789,42.4210526 C22.0589474,42.4210526 20.7894737,41.1515789 20.7894737,39.5789474 C20.7894737,38.0063158 22.0589474,36.7368421 23.6315789,36.7368421 C25.2042105,36.7368421 26.4736842,38.0063158 26.4736842,39.5789474 C26.4736842,41.1515789 25.2042105,42.4210526 23.6315789,42.4210526 L23.6315789,42.4210526 Z M17,40.5263158 C17,42.2025263 17.7389474,43.6905263 18.8947368,44.7326316 L18.8947368,48.1052632 C18.8947368,49.1473684 19.7473684,50 20.7894737,50 L22.6842105,50 C23.7364211,50 24.5789474,49.1473684 24.5789474,48.1052632 L24.5789474,46.2105263 L39.7368421,46.2105263 L39.7368421,48.1052632 C39.7368421,49.1473684 40.5793684,50 41.6315789,50 L43.5263158,50 C44.5684211,50 45.4210526,49.1473684 45.4210526,48.1052632 L45.4210526,44.7326316 C46.5768421,43.6905263 47.3157895,42.2025263 47.3157895,40.5263158 L47.3157895,21.5789474 C47.3157895,14.9473684 40.5326316,14 32.1578947,14 C23.7831579,14 17,14.9473684 17,21.5789474 L17,40.5263158 Z"
                fill="${this.color}"></path>
            </g>
          </svg>
        </div>
        <div class="direction">${this.direction}</div>
        <div class="headsign">${this.headsign}</div>
        <div class="leaving-in-label">${this.data.leaving_in_label}</div>
        <div class="leaving-in">${this.data.leaving_in}</div>
      </div>
      <div class="body" style="height: ${this.bodyHeight}px">
        <div class="stop-times">
          ${this.stopTimes}
        </div>
        ${this.stopGuide}
        <div class="stop-names">
          ${this.stopNames}
        </div>
        <div class="next-in-box">
          <div class="next-in-label">${this.data.next_in_label}</div>
          <div class="next-in">${this.data.next_in}</div>
        </div>
      </div>
    </div>`;
  }

  get stopTimes() {
    return this.data.next_trip.stop_info
      .map(stop_info => {
        return `<div class="stop-time">${stop_info.departure_time}</div>`;
      })
      .join('\n');
  }

  get stopNames() {
    return this.data.next_trip.stop_info
      .map(stop_info => {
        return `<div class="stop-name">${stop_info.stop_name}</div>`;
      })
      .join('\n');
  }

  get stopGuide() {
    switch (this.data.next_trip.stop_info.length) {
      case 2:
        return `
        <div class="stop-guide">
          <svg width="16px" height="56px" viewBox="0 0 16 56">
            <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
                <g id="ic_stops_one">
                    <g id="top">
                        <g id="bg" fill="${this.color}">
                            <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z" id="fill"></path>
                        </g>
                        <ellipse id="fill_white" fill="#FFFFFF" cx="8" cy="8" rx="5" ry="5"></ellipse>
                    </g>
                    <g id="bottom" transform="translate(0.000000, 28.000000)">
                        <g id="bg" transform="translate(8.000000, 14.000000) scale(1, -1) translate(-8.000000, -14.000000) " fill="${this.color}">
                            <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z M8,13 C10.7614237,13 13,10.7614237 13,8 C13,5.23857625 10.7614237,3 8,3 C5.23857625,3 3,5.23857625 3,8 C3,10.7614237 5.23857625,13 8,13 Z" id="fill"></path>
                        </g>
                        <ellipse id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" rx="5" ry="5"></ellipse>
                    </g>
                </g>
            </g>
          </svg>
        </div>`;
      case 3:
        return `
        <div class="stop-guide">
          <svg width="16px" height="96px" viewBox="0 0 16 96">
              <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
                  <g id="ic_stops_two">
                      <g id="top">
                          <g id="bg" fill="${this.color}">
                              <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z" id="fill"></path>
                          </g>
                          <ellipse id="fill_white" fill="#FFFFFF" cx="8" cy="8" rx="5" ry="5"></ellipse>
                      </g>
                      <g id="middle" transform="translate(0.000000, 28.000000)">
                          <g id="bg" fill="${this.color}">
                              <path d="M12,20 L16,20 L16,1.77635684e-14 L0,1.77635684e-14 L0,20 L4,20 C4,17.790861 5.790861,16 8,16 C10.209139,16 12,17.790861 12,20 L16,20 L16,40 L0,40 L0,20 L4,20 C4,22.209139 5.790861,24 8,24 C10.209139,24 12,22.209139 12,20 Z" id="fill"></path>
                          </g>
                          <circle id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" r="4"></circle>
                      </g>
                      <g id="bottom" transform="translate(0.000000, 68.000000)">
                          <g id="bg" transform="translate(8.000000, 14.000000) scale(1, -1) translate(-8.000000, -14.000000) " fill="${this.color}">
                              <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z M8,13 C10.7614237,13 13,10.7614237 13,8 C13,5.23857625 10.7614237,3 8,3 C5.23857625,3 3,5.23857625 3,8 C3,10.7614237 5.23857625,13 8,13 Z" id="fill"></path>
                          </g>
                          <ellipse id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" rx="5" ry="5"></ellipse>
                      </g>
                  </g>
              </g>
          </svg>
        </div>`;
      case 4:
        return `
        <div class="stop-guide">
          <svg width="16px" height="136px" viewBox="0 0 16 136">
              <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
                  <g id="ic_stops_three">
                      <g id="top">
                          <g id="bg" fill="${this.color}">
                              <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z" id="fill"></path>
                          </g>
                          <ellipse id="fill_white" fill="#FFFFFF" cx="8" cy="8" rx="5" ry="5"></ellipse>
                      </g>
                      <g id="middle" transform="translate(0.000000, 28.000000)">
                          <g id="bg" fill="${this.color}">
                              <path d="M12,20 L16,20 L16,1.77635684e-14 L0,1.77635684e-14 L0,20 L4,20 C4,17.790861 5.790861,16 8,16 C10.209139,16 12,17.790861 12,20 L16,20 L16,40 L0,40 L0,20 L4,20 C4,22.209139 5.790861,24 8,24 C10.209139,24 12,22.209139 12,20 Z" id="fill"></path>
                          </g>
                          <circle id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" r="4"></circle>
                      </g>
                      <g id="middle-copy" transform="translate(0.000000, 68.000000)">
                          <g id="bg" fill="${this.color}">
                              <path d="M12,20 L16,20 L16,1.77635684e-14 L0,1.77635684e-14 L0,20 L4,20 C4,17.790861 5.790861,16 8,16 C10.209139,16 12,17.790861 12,20 L16,20 L16,40 L0,40 L0,20 L4,20 C4,22.209139 5.790861,24 8,24 C10.209139,24 12,22.209139 12,20 Z" id="fill"></path>
                          </g>
                          <circle id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" r="4"></circle>
                      </g>
                      <g id="bottom" transform="translate(0.000000, 108.000000)">
                          <g id="bg" transform="translate(8.000000, 14.000000) scale(1, -1) translate(-8.000000, -14.000000) " fill="${this.color}">
                              <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z M8,13 C10.7614237,13 13,10.7614237 13,8 C13,5.23857625 10.7614237,3 8,3 C5.23857625,3 3,5.23857625 3,8 C3,10.7614237 5.23857625,13 8,13 Z" id="fill"></path>
                          </g>
                          <ellipse id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" rx="5" ry="5"></ellipse>
                      </g>
                  </g>
              </g>
          </svg>
        </div>`;
      case 5:
        return `
        <div class="stop-guide">
          <svg width="16px" height="176px" viewBox="0 0 16 176">
              <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
                  <g id="ic_stops_four">
                      <g id="top">
                          <g id="bg" fill="${this.color}">
                              <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z" id="fill"></path>
                          </g>
                          <ellipse id="fill_white" fill="#FFFFFF" cx="8" cy="8" rx="5" ry="5"></ellipse>
                      </g>
                      <g id="middle" transform="translate(0.000000, 28.000000)">
                          <g id="bg" fill="${this.color}">
                              <path d="M12,20 L16,20 L16,-1.77635684e-14 L0,-1.77635684e-14 L0,20 L4,20 C4,17.790861 5.790861,16 8,16 C10.209139,16 12,17.790861 12,20 L16,20 L16,40 L0,40 L0,20 L4,20 C4,22.209139 5.790861,24 8,24 C10.209139,24 12,22.209139 12,20 Z" id="fill"></path>
                          </g>
                          <circle id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" r="4"></circle>
                      </g>
                      <g id="middle-copy" transform="translate(0.000000, 68.000000)">
                          <g id="bg" fill="${this.color}">
                              <path d="M12,20 L16,20 L16,-1.77635684e-14 L0,-1.77635684e-14 L0,20 L4,20 C4,17.790861 5.790861,16 8,16 C10.209139,16 12,17.790861 12,20 L16,20 L16,40 L0,40 L0,20 L4,20 C4,22.209139 5.790861,24 8,24 C10.209139,24 12,22.209139 12,20 Z" id="fill"></path>
                          </g>
                          <circle id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" r="4"></circle>
                      </g>
                      <g id="middle-copy-2" transform="translate(0.000000, 108.000000)">
                          <g id="bg" fill="${this.color}">
                              <path d="M12,20 L16,20 L16,-1.77635684e-14 L0,-1.77635684e-14 L0,20 L4,20 C4,17.790861 5.790861,16 8,16 C10.209139,16 12,17.790861 12,20 L16,20 L16,40 L0,40 L0,20 L4,20 C4,22.209139 5.790861,24 8,24 C10.209139,24 12,22.209139 12,20 Z" id="fill"></path>
                          </g>
                          <circle id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" r="4"></circle>
                      </g>
                      <g id="bottom" transform="translate(0.000000, 148.000000)">
                          <g id="bg" transform="translate(8.000000, 14.000000) scale(1, -1) translate(-8.000000, -14.000000) " fill="${this.color}">
                              <path d="M16,8 C16,3.581722 12.418278,0 8,0 C3.581722,0 0,3.581722 0,8 L0,28 L16,28 L16,8 Z M8,13 C10.7614237,13 13,10.7614237 13,8 C13,5.23857625 10.7614237,3 8,3 C5.23857625,3 3,5.23857625 3,8 C3,10.7614237 5.23857625,13 8,13 Z" id="fill"></path>
                          </g>
                          <ellipse id="fill_opacity" fill="#FFFFFF" cx="8" cy="20" rx="5" ry="5"></ellipse>
                      </g>
                  </g>
              </g>
          </svg>
        </div>`;
    }
  }
}

function renderCard(data) {
  const card = new Card(data);
  return card.render;
}

function renderSide(side) {
  if (side) {
    return `<div class="side">${side
      .map(card => {
        return renderCard(card);
      })
      .join('')}</div>`;
  }
  return '';
}

function renderPanels(panels) {
  if (!panels) {
    return '';
  }

  return panels
    .map(panel => {
      return `<div class="panel">
              ${renderSide(panel.left)}
              ${renderSide(panel.right)}
            </div>`;
    })
    .join('');
}

function initMap() {
  const map = new google.maps.Map(document.getElementById('map'), {
    disableDefaultUI: true,
    styles: mapStyle
  });

  // Put I/O on the map
  geocodeAddress(
    '1 Amphitheatre Pkwy, Mountain View, CA 94043',
    map,
    '/images/dashboard/logo_io_64.png',
    'Google I/O'
  );

  const busMarkerManager = new BusMarkerManager(map);
  const hotelMarkerManager = new HotelMarkerManager(map);
  const cardsElement = document.querySelector('#cards');
  const displayTimeElement = document.querySelector('#display-time');
  const pageMarkerPanelElts = [
    document.querySelector('#page-marker-panel-0'),
    document.querySelector('#page-marker-panel-1'),
    document.querySelector('#page-marker-panel-2')
  ];
  const db = firebase.database();

  db.ref('current-time').on('value', snapshot => {
    displayTimeElement.textContent = snapshot.val().display;
  });

  db.ref('map').on('value', snapshot => {
    const val = snapshot.val();
    map.fitBounds({
      east: val.northEastLng,
      north: val.northEastLat,
      south: val.southWestLat,
      west: val.southWestLng
    });

    hotelMarkerManager.update(val.markers);

    cardsElement.style[
      'top'
    ] = `calc(${val.panel * -100}vh - ${val.panel * 64}px)`;

    pageMarkerPanelElts.forEach(elt => {
      elt.classList.remove('selected');
    });
    pageMarkerPanelElts[val.panel].classList.add('selected');
  });

  db.ref('panels').on('value', snapshot => {
    cardsElement.innerHTML = renderPanels(snapshot.val());
  });

  db.ref('bus-locations').on('value', snapshot => {
    busMarkerManager.update(snapshot.val());
  });
}

Now all that remains is one final re-deploy, and you have your own complete real time asset tracker solution! Congratulations!

npm run firebase-deploy