Display markers for vehicles page

Learn how to listen to changes in properties on your custom elements and how to properly reflect back the property values to the outside world.

Overview

In this part we will:

  • Update the <google-map-view> component to display markers.
  • Receive an array of vehicles as a property from the <bus-tracker> parent component.
  • Add a marker for each vehicle using the embedded Googles Maps widget.
  • Reflect back the array of vehicles and array of markers as properties on the <google-map-view>.

Problem

In the previous exercise we fetched a list of vehicles when a route is selected and logged them to the console. Now we want to instead pass those to the google-map-view element. The google-map-view element should add a marker for each vehicle.

When an error occurs in the API it should wipe away any existing markers.

How to Solve This Problem

  1. Update the bus-tracker component to pass the vehicles list to the google-map-view via the vehicles property.
  2. Add a getter/setter pair on the google-map-view to handle .vehicles. When set it should use the Marker snippet (below) to create a new marker for each vehicle.
  3. When a route is selected and markers are already displayed for a previous route, remove the previous markers.

Technical requirements

To create a new marker use new google.maps.Marker. This takes an object with some options that look like this:

new google.maps.Marker({
  position: {
    lat: latitude,
    lng: longitude
  },
  map: googleMapObject
});

In this case map is the thing we created in a previous exercise by calling new google.maps.Map.

Additionally this snippet can be used to remove a marker:

marker.setMap(null);

What you need to know

  • How to use JavaScript getters and setters to handle dynamic property values.
  • How to use default values in custom elements.

getters/setters

JavaScript setters allow observation of property changes. Adding a setter for vehicles provides a hook for when the <bus-tracker> component passes the array of vehicles for the selected route.

In order to reflect back the list of vehicles in a getter you can save the vehicle list to another property on the element (like an underscore property). It’s common when a setter exists that a getter does as well.

This is an example of a getter/setter pair in a JavaScript class.

class Person {
  set age(val) {
    console.log('Setting age');
    this._age = val;
  }

  get age() {
    console.log('Getting age');
    return this._age;
  }
}

let kid = new Person();
kid.age = 4;

console.log(kid.age);

We can use getters/setters within custom element classes as well.

<my-counter></my-counter>

<script type="module">
class CounterElement extends HTMLElement {
  constructor() {
    super();
    this._count = 0;
    this.render();
  }

  render() {
    this.innerHTML = `Count: ${this.count}`;
  }

  get count() {
    return this._count;
  }

  set count(value) {
    this._count = value;
    this.render();
  }
}

customElements.define('my-counter', CounterElement);

let counter = document.querySelector('my-counter');

setTimeout(() => counter.count++, 5000);
setTimeout(() => counter.count = 15, 10000);
setTimeout(() => counter.count--, 15000);
</script>

Default values

Most properties supported by built-in elements have some sort of default value. For example the <progress> element has a max property that defaults to 1: document.createElement('progress').max; // 1. All elements have an onclick property whose default value is null. It’s good practice to provide default values for your supported public properties, and these can be set in the constructor. Combining getters, setters and default values for properties makes your component more robust.

class DogElement extends HTMLElement {
  constructor() {
    super();
    this._breed = null;
  }
  get breed() {
    return this._breed;
  }
  set breed(val) {
    this._breed = val;
  }
}

Solution

✏️ Add default values for markers and vehicles in the constructor (use an underscore property for vehicles). Add a getter/setter pair for vehicles, where the setter creates new markers on the map with new google.maps.Marker.

Click to see the solution

<style>
  html,
  body {
    height: 100%;
  }
  body {
    font-family: "Catamaran", sans-serif;
    background-color: #f2f2f2;
    display: flex;
    flex-direction: column;
    margin: 0;
  }
</style>
<script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyD7POAQA-i16Vws48h4yRFVGBZzIExOAJI"></script>

<bus-tracker></bus-tracker>

<template id="bt-template">
  <style>
    :host {
      display: flex;
      flex-direction: column;
    }

    .top {
      flex-grow: 1;
      overflow-y: auto;
      height: 10%;
      display: flex;
      flex-direction: column;
    }

    footer {
      height: 250px;
      position: relative;
    }
    .gmap {
      width: 100%;
      height: 250px;
      background-color: grey;
    }

    header {
      box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.1);
      background-color: #313131;
      color: white;
      min-height: 60px;
      display: flex;
      flex-direction: column;
      justify-content: center;
      line-height: 1.2;
    }

    header h1 {
      text-align: center;
      font-size: 18px;
      text-transform: uppercase;
      letter-spacing: 1px;
      margin: 0;
    }
    #selected-route:not(.route-selected) {
      display: none;
    }
    .route-selected {
      line-height: 1;
      position: absolute;
      z-index: 1;
      text-align: right;
      background: rgba(6, 6, 6, 0.6);
      top: 10px;
      right: 10px;
      padding: 6px 10px;
      color: white;
      border-radius: 2px;
      cursor: pointer;
    }
    .route-selected small {
      display: block;
      font-size: 14px;
      color: #ddd;
    }
    .route-selected .error-message {
      font-size: 14px;
      background-color: #ff5722;
      border-radius: 10px;
      padding: 4px 8px 1px;
      margin-top: 5px;
    }
    .routes-list {
      padding: 20px 0;
      margin: 0;
      overflow-y: auto;
    }
    .routes-list li {
      list-style: none;
      cursor: pointer;
      background: white;
      border: 1px solid #dedede;
      margin: 1% 2%;
      border-radius: 25px;
      color: #2196f3;
      width: 41%;
      display: inline-flex;
      font-size: 14px;
      line-height: 1.2;
    }
    .routes-list li:hover {
      border-color: transparent;
      background-color: #008eff;
      color: white;
      box-shadow: 0px 5px 20px 0px rgba(0, 0, 0, 0.2);
    }
    .routes-list li .check {
      display: none;
    }
    .routes-list li.active {
      color: #666;
      background-color: #e8e8e8;
    }
    .routes-list li.active .check {
      display: inline-block;
      margin-left: 5px;
      color: #2cc532;
    }
    .routes-list li.active:hover {
      border-color: #dedede;
      box-shadow: none;
    }
    .routes-list button {
      width: 100%;
      padding: 8px 8px 6px;
      border: none;
      border-radius: 25px;
      background: transparent;
      text-align: left;
      font: inherit;
      color: inherit;
    }
    .route-number {
      display: inline-block;
      border-right: 1px solid #dedede;
      padding-right: 5px;
      margin-right: 5px;
      min-width: 18px;
      text-align: right;
    }
    p {
      text-align: center;
      margin: 0;
      color: #ccc;
      font-size: 14px;
    }
  </style>
  <div class="top">
    <header>
      <h1>Chicago CTA Bus Tracker</h1>
      <p id="loading-routes">Loading routes…</p>
    </header>

    <ul class="routes-list"></ul>
  </div>
  <footer>
    <button id="selected-route" type="button">
    </button>

    <google-map-view></google-map-view>
  </footer>
</template>
<template id="error-template">
  <div class="error-message">
    No vehicles available for this route
  </div>
</template>
<template id="gmap-template">
  <style>
    .gmap {
      width: 100%;
      height: 250px;
      background-color: grey;
    }
  </style>
  <div class="gmap"></div>
</template>
<template id="route-template">
  <li>
    <button type="button">
      <span class="route-number"></span>
      <span class="route-name"></span>
      <span class="check">✔</span>
    </button>
  </li>
</template>
<script type="module">
const template = document.querySelector('#gmap-template');

class GoogleMapView extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    let nodes = document.importNode(template.content, true);
    this.shadowRoot.append(nodes);

    this.markers = null;
    this._vehicles = null;
  }

  connectedCallback() {
    let gmap = this.shadowRoot.querySelector('.gmap');
    this.map = new google.maps.Map(gmap, {
      zoom: 10,
      center: {
        lat: 41.881,
        lng: -87.623
      }
    });
  }

  get vehicles() {
    return this._vehicles;
  }

  set vehicles(newVehicles) {
    this._vehicles = newVehicles;
    if (this.markers) {
      for(let marker of this.markers) {
        marker.setMap(null);
      }
      this.markers = null;
    }
    if (newVehicles) {
      this.markers = newVehicles.map(vehicle => {
        return new google.maps.Marker({
          position: {
            lat: parseFloat(vehicle.lat),
            lng: parseFloat(vehicle.lon)
          },
          map: this.map
        });
      });
    }
  }
}

customElements.define('google-map-view', GoogleMapView);

const apiRoot = "https://cta-bustracker.vercel.app/api/";
const getRoutesEndpoint = apiRoot + "routes";
const getVehiclesEndpoint = apiRoot + "vehicles";

const btTemplate = document.querySelector('#bt-template');
const routeTemplate = document.querySelector('#route-template');
const errorTemplate = document.querySelector('#error-template');

class BusTracker extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });

    let frag = document.importNode(btTemplate.content, true);
    this.shadowRoot.append(frag);

    this.routesList = this.shadowRoot.querySelector('.routes-list');
    this.selectedRouteBtn = this.shadowRoot.querySelector('#selected-route');
    this.googleMapView = this.shadowRoot.querySelector('google-map-view');
  }

  connectedCallback() {
    this.getRoutes();
  }

  async getRoutes() {
    let response = await fetch(getRoutesEndpoint);
    let data = await response.json();
    let routes = data["bustime-response"].routes;

    for(let route of routes) {
      let frag = document.importNode(routeTemplate.content, true);

      frag.querySelector('.route-number').textContent = route.rt;
      frag.querySelector('.route-name').textContent = route.rtnm;

      frag.querySelector('button').addEventListener('click', ev => {
        this.pickRoute(route, ev.currentTarget.parentNode);
      });

      this.routesList.append(frag);
    }

    this.shadowRoot.querySelector('#loading-routes').remove();
  }

  async getVehicles(route) {
    let response = await fetch(getVehiclesEndpoint + '?rt=' + route.rt);
    let data = await response.json();

    this.selectedRouteBtn.innerHTML = `
      <small>Route ${this.route.rt}:</small> ${this.route.rtnm}
    `;

    if (data['bustime-response'].error) {
      let frag = document.importNode(errorTemplate.content, true);
      this.selectedRouteBtn.append(frag);
      this.googleMapView.vehicles = [];
    } else {
      let vehicles = data['bustime-response'].vehicle;
      this.googleMapView.vehicles = vehicles;
    }

    this.selectedRouteBtn.classList.add('route-selected');
  }

  pickRoute(route, li) {
    this.route = route;
    this.getVehicles(route);

    if(this.activeRoute) {
      this.activeRoute.classList.remove('active');
    }
    this.activeRoute = li;
    this.activeRoute.classList.add('active');
  }
}

customElements.define("bus-tracker", BusTracker);
</script>