Build An Interactive Game of Thrones Map (Part II) - Leaflet.js & Webpack

The Exciting World of Digital Cartography

Welcome to part II of the tutorial series "Build An Interactive Game of Thrones Map". In this installment, we'll be building a web application to display from from our "Game of Thrones" API on an interactive map.

Our webapp is built on top of the backend application that we completeted in part I of the tutorial - Build An Interactive Game of Thrones Map (Part I) - Node.js, PostGIS, and Redis

Using the techniques that we'll cover for this example webapp, you will have a foundation to build any sort of interactive web-based map, from "A Day in the Life of an NYC Taxi Cab" to a completely open-source version of Google Maps.

We will also be going over the basics of wiring up a simple Webpack build system, along with covering some guidelines for creating frameworkless Javascript components.

For a preview of the final result, check out the webapp here - https://atlasofthrones.com

Map Preview

We will be using Leaflet.js to render the map, Fuse.js to power the location search, and Sass for our styling, all wrapped in a custom Webpack build system. The application will be built using vanilla Javascript (no frameworks), but we will still organize the codebase into seperate UI components (with seperate HTML, CSS, and JS files) for maximal clarity and seperation-of-concerns.

Part 0 - Project Setup

0.0 - Install Dependencies

I'll be writing this tutorial with the assumption that anyone reading it has already completed the first part - Build An Interactive Game of Thrones Map (Part I) - Node.js, PostGIS, and Redis.

If you stubbornly refuse to learn about the Node.js backend powering this application, I'll provide an API URL that you can use instead of your running the backend on your own machine. But seriously, try part one, it's pretty fun.

To setup the project, you can either resume from the same project directory where you completed part one, or you can clone the frontend starter repo to start fresh with the complete backend application.

Option A - Use Your Codebase from Part I

If you are resuming from your existing backend project, you'll just need to install a few new NPM dependencies.

npm i -D webpack html-loader node-sass sass-loader css-loader style-loader url-loader babel-loader babili-webpack-plugin http-server
npm i axios fuse.js leaflet

And that's it, with the dependencies installed you should be good to go.

Option B - Use Frontend-Starter Github Repository

First, git clone the frontend-starter branch of the repository on Github.

git clone -b frontend-starter https://github.com/triestpa/Atlas-Of-Thrones

Once the repo is download, enter the directory (cd Atlas-Of-Thrones) and run npm install.

You'll still need to set up PostgreSQL and Redis, along with adding a local .env file. See parts 1 and 2 of the backend tutorial for details.

0.1 - But wait - where's the framework?

Right... I decided not to use any specific Javascript framework for this tutorial. In the past, I've used React, Angular (1.x & 2.x+), and Vue (my personal favorite) for a variety of projects. I think that they're all really solid choices.

When writing a tutorial, I would prefer not to alienate anyone who is inexperienced with (or has a deep dogmatic hatred of) the chosen framework, so I've chosen to build the app using the native Javascript DOM APIs.

Why?

  • The app is relatively simple and does not require advanced page routing or data binding.
  • Leaflet.js handles the complex map rendering and styling
  • I want to keep the tutorial accessible to anyone who knows Javascript, without requiring knowledge of any specific framework.
  • Omitting a framework allows us to minimize the base application payload size to 60kb total (JS+CSS), most of which (38kb) is Leaflet.js.
  • Building a frameworkless frontend is a valuable "back-to-basics" Javascript exercise.

Am I against Javascript frameworks in general? Of course not! I use JS frameworks for almost all of my (personal and profession) projects.

But what about project structure? And reusable components?
That's a good point. Frameworkless frontend applications too often devolve into a monolithic 1000+ line single JS file (along with huge HTML and CSS files), full of spaghetti code and nearly impossible to decipher for those who didn't originally write it. I'm not a fan of this approach.

What if I told you that it's possible to write structured, reusable Javascript components without a framework? Blasphemy? Too difficult? Not at all. We'll go deeper into this further down.

0.2 - Setup Webpack Config

Before we actually start coding the webapp, let's get the build system in place. We'll be using Webpack to bundle our JS/CSS/HTML files, to generate source maps for dev builds, and to minimize resources for production builds.

Create a webpack.config.js file in the project root.

const path = require('path')
const BabiliPlugin = require('babili-webpack-plugin')

// Babel loader for Transpiling ES8 Javascript for browser usage
const babelLoader = {
  test: /\.js$/,
  loader: 'babel-loader',
  include: [path.resolve(__dirname, '../app')],
  query: { presets: ['es2017'] }
}

// SCSS loader for transpiling SCSS files to CSS
const scssLoader = {
  test: /\.scss$/,
  loader: 'style-loader!css-loader!sass-loader'
}

// URL loader to resolve data-urls at build time
const urlLoader = {
  test: /\.(png|woff|woff2|eot|ttf|svg)$/,
  loader: 'url-loader?limit=100000'
}

// HTML load to allow us to import HTML templates into our JS files
const htmlLoader = {
  test: /\.html$/,
  loader: 'html-loader'
}

const webpackConfig = {
  entry: './app/main.js', // Start at app/main.js
  output: {
    path: path.resolve(__dirname, 'public'),
    filename: 'bundle.js' // Output to public/bundle.js
  },
  module: { loaders: [ babelLoader, scssLoader, urlLoader, htmlLoader ] }
}

if (process.env.NODE_ENV === 'production') {
  // Minify for production build
  webpackConfig.plugins = [ new BabiliPlugin({}) ]
} else {
  // Generate sourcemaps for dev build
  webpackConfig.devtool = 'eval-source-map'
}

module.exports = webpackConfig

I won't explain the Webpack config here in-depth since we've got a long tutorial ahead of us. I hope that the inline-comments will adequately explain what each piece of the configuration does; for a more thorough introduction to Webpack, I would recommend the following resources -

0.3 - Add NPM Scripts

In the package.json file, add the following scripts -

"scripts": {
  ...
  "serve": "webpack --watch & http-server ./public",
  "dev": "NODE_ENV=local npm start & npm run serve",
  "build": "NODE_ENV=production webpack"
}

Since we're including the frontend code in the same repository as the backend Node.js application, we'll leave the npm start command reserved for starting the server.

The new npm run serve script will watch our frontend source files, build our application, and serve files from the public directory at localhost:8080.

The npm run build command will build a production-ready (minified) application bundle.

The npm run dev command will start the Node.js API server and serve the webapp, allowing for an integrated (backend + frontend) development environment start command.

You could also use the NPM module webpack-dev-server to watch/build/serve the frontend application dev bundle with a single command. Personally, I prefer the flexibility of keeping these tasks decoupled by using webpack --watch with the http-server NPM module.

0.3 - Add public/index.html

Create a new directory called public in the project root.

This is the repository where the public webapp code will be generated. The only file that we need here is an "index.html" page in order to import our dependencies and to provide a placeholder element for the application to load into.

Add to following to public/index.html.

<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="x-ua-compatible" content="ie=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, shrink-to-fit=no">
  <title>Atlas Of Thrones: A Game of Thrones Interactive Map</title>
  <meta name="description" content="Explore the world of Game of Thrones! An interactive Google Maps style webapp." />
  <style>
    html {
      background: #222;
    }

    #loading-container {
      font-family: sans-serif;
      position: absolute;
      color: white;
      letter-spacing: 0.8rem;
      text-align: center;
      top: 40%;
      width: 100%;
      text-transform: uppercase;
    }
  </style>
</head>

<body>
  <div id="loading-container">
    <h1>Atlas of Thrones</h1>
  </div>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

Here, we are simply importing our bundle (bundle.js) and adding a placeholder div to load our app into.

We are rendering an "Atlas of Thrones" title screen that will be displayed to the user instantly and be replaced once the app bundle is finished loading. This is a good practice for single-page javascript apps (which can often have large payloads) in order to replace the default blank browser loading screen with some app-specific content.

0.4 - Add app/main.js

Now create a new directory app/. This is where our pre-built frontend application code will live.

Create a file, app/main.js, with the following contents.

/** Main UI Controller Class */
class ViewController {
  /** Initialize Application */
  constructor () {
    console.log('hello world')
  }
}

window.ctrl = new ViewController()

We are just creating a (currently useless class) ViewController, and instantiating it. The ViewController class will be our top-level full-page controller, which we will use to instantiate and compose the various application components.

0.5 - Try it out!

Ok, now we're ready to run our very basic application template. Run npm run serve on the command line. We should see some output text indicating a successful Webpack application build, as well as some http-server output notifying us that the public directory is now being served at localhost:8080.

$ npm run serve

> [email protected] serve 
> webpack --watch & http-server ./public

Starting up http-server, serving ./public
Available on:
  http://127.0.0.1:8080
  http://10.3.21.159:8080
Hit CTRL-C to stop the server

Webpack is watching the files…

Hash: 5bf9c88ced32655e0ca3
Version: webpack 3.5.5
Time: 77ms
    Asset     Size  Chunks             Chunk Names
bundle.js  3.41 kB       0  [emitted]  main
   [0] ./app/main.js 178 bytes {0} [built]

Visit localhost:8080 in your browser; you should see the following screen.

Loading Screen

Open up the browser Javascript console (In Chrome - Command+Option+J on macOS, Control+Shift+J on Windows / Linux). You should see a hello world message in the console output.

0.6 - Add baseline app HTML and SCSS

Now that our Javascript app is being loaded correctly, we'll add our baseline HTML template and SCSS styling.

In the app/ directory, add a new file - main.html, with the following contents.

<div id="app-container">
  <div id="map-placeholder"></div>
  <div id="layer-panel-placeholder"></div>
  <div id="search-panel-placeholder"></div>
  <div id="info-panel-placeholder"></div>
</div>

This file simply contains placeholders for the components that we will soon add.

Next add _variables.scss to the app/ directory.

$offWhite: #faebd7;
$grey: #666;
$offDark: #111;
$midDark: #222;
$lightDark: #333;
$highlight: #BABA45;
$highlightSecondary: red;
$footerHeight: 80px;
$searchBarHeight: 60px;
$panelMargin: 24px;
$toggleLayerPanelButtonWidth: 40px;
$leftPanelsWidth: 500px;
$breakpointMobile: 600px;
$fontNormal: 'Lato', sans-serif;
$fontFancy: 'MedievalSharp', cursive;

Here we are defining some of our global style variables, which will make it easier to keep the styling consistent in various component-specific SCSS files.

To define our global styles, create main.scss in the app/ directory.

@import url(https://fonts.googleapis.com/css?family=Lato|MedievalSharp);
@import "~leaflet/dist/leaflet.css";
@import './_variables.scss';

/** Page Layout **/
body {
  margin: 0;
  font-family: $fontNormal;
  background: $lightDark;
  overflow: hidden;
  height: 100%;
  width: 100%;
}

a {
  color: $highlight;
}

#loading-container {
  display: none;
}

#app-container {
  display: block;
}

At the top of the file, we are importing three items - our application fonts from Google Fonts, the Leaflet CSS styles, and our SCSS variables. Next, we are setting some very basic top-level styling rules, and we are hiding the loading container.

I won't be going into the specifics of the CSS/SCSS styling during this tutorial, since the tutorial is already quite long, and explaining CSS rules in-depth tends to be tedious. The provided styling is designed to be minimal, extensible, and completely responsive for desktop/tablet/mobile usage, so feel free to modify it with whatever design ideas you might have.

Finally, edit our app/main.js file to have the following contents.

import './main.scss'
import template from './main.html'

/** Main UI Controller Class */
class ViewController {
  /** Initialize Application */
  constructor () {
    document.getElementById('app').outerHTML = template
  }
}

window.ctrl = new ViewController()

We've changed the application behavior now to import our global SCSS styles and to load the base application HTML template into the app id placeholder (as defined in public/index.html).

Check the terminal output (re-run npm run serve if you stopped it), you should see successful build output from Webpack. Open localhost:8080 in you browser, and you should now see an empty dark screen. This is good since it means that the application SCSS styles have been loaded correctly, and have hidden the loading placeholder container.

For a more thorough test, you can use the browser's network throttling settings to load the page slowly (I would recommend testing everything you build using the Slow 3G setting). With network throttling enabled (and the browser cache disabled), you should see the "Atlas Of Thrones" loading screen appear for a few seconds, and then disappear once the application bundle is loaded.

Step 1 - Add Native Javascript Component Structure

We will now set up a simple way to create frameworkless Javascript components.

Add a new directory - app/components.

1.0 - Add base Component class

Create a new file app/components/component.js with the following contents.

/**
 * Base component class to provide view ref binding, template insertion, and event listener setup
 */
export class Component {
  /** SearchPanel Component Constructor
   * @param { String } placeholderId - Element ID to inflate the component into
   * @param { Object } props - Component properties
   * @param { Object } props.events - Component event listeners
   * @param { Object } props.data - Component data properties
   * @param { String } template - HTML template to inflate into placeholder id
   */
  constructor (placeholderId, props = {}, template) {
    this.componentElem = document.getElementById(placeholderId)

    if (template) {
      // Load template into placeholder element
      this.componentElem.innerHTML = template

      // Find all refs in component
      this.refs = {}
      const refElems = this.componentElem.querySelectorAll('[ref]')
      refElems.forEach((elem) => { this.refs[elem.getAttribute('ref')] = elem })
    }

    if (props.events) { this.createEvents(props.events) }
  }

  /** Read "event" component parameters, and attach event listeners for each */
  createEvents (events) {
    Object.keys(events).forEach((eventName) => {
      this.componentElem.addEventListener(eventName, events[eventName], false)
    })
  }

  /** Trigger a component event with the provided "detail" payload */
  triggerEvent (eventName, detail) {
    const event = new window.CustomEvent(eventName, { detail })
    this.componentElem.dispatchEvent(event)
  }
}

This is the base component class that all of our custom components will extend.

The component class handles three important tasks

  1. Load the component HTML template into the placeholder ID.
  2. Assign each DOM element with a ref tag to this.refs.
  3. Bind window event listener callbacks for the provided event types.

It's ok if this seems a bit confusing right now, we'll see soon how each tiny block of code will provide essential functionality for our custom components.

If you are unfamiliar with object-oriented programming and/or class-inheritance, it's really quite simple, here are some Javascript-centric introductions.

Javascript "Classes" are syntactic sugar over Javascript's existing prototype-based object declaration and inheritance model. This differs from "true" object-oriented languages, such as Java, Python, and C++, which were designed from the ground-up with class-based inheritance in mind. Despite this caveat (which is necessary due to the existing limitations in legacy browser Javascript interpreter engines), the new Javascript class syntax is really quite useful and is much cleaner and more standardized (i.e. more similar to virtually every other OOP language) than the legacy prototype-based inheritance syntax.

1.1 - Add Baseline info-panel Component

To demonstrate how our base component class works, let's create an info-panel component.

First create a new directory - app/components/info-panel.

Next, add an HTML template in app/components/info-panel/info-panel.html

<div ref="container" class="info-container">
  <div ref="title" class="info-title">
    <h1>Nothing Selected</h1>
  </div>
  <div class="info-body">
    <div class="info-content-container">
      <div ref="content" class="info-content"></div>
    </div>
  </div>
</div>

Notice that some of the elements have the ref attribute defined. This is a system (modeled on similar features in React and Vue) to make these native HTML elements (ex. ref="title") easily accessible from the component (using this.refs.title). See the Component class constructor for the simple implementation details of how this system works.

We'll also add our component styling in app/components/info-panel/info-panel.scss

@import '../../_variables.scss';

.info-title {
  font-family: $fontFancy;
  height: $footerHeight;
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  user-select: none;
  color: $offWhite;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;

  h1 {
    letter-spacing: 0.3rem;
    max-width: 100%;
    padding: 20px;
    text-overflow: ellipsis;
    text-align: center;
  }
}

.info-content {
  padding: 0 8% 24px 8%;
  margin: 0 auto;
  background: $lightDark;
  overflow-y: scroll;
  font-size: 1rem;
  line-height: 1.25em;
  font-weight: 300;
}

.info-container {
  position: absolute;
  overflow-y: hidden;
  bottom: 0;
  left: 24px;
  z-index: 1000;
  background: $midDark;
  width: $leftPanelsWidth;
  height: 60vh;
  color: $offWhite;
  transition: all 0.4s ease-in-out;
  transform: translateY(calc(100% - #{$footerHeight}));
}

.info-container.info-active {
  transform: translateY(0);
}

.info-body {
  margin-top: $footerHeight;
  overflow-y: scroll;
  overflow-x: hidden;
  position: relative;
  height: 80%;
}

.info-footer {
  font-size: 0.8rem;
  font-family: $fontNormal;
  padding: 8%;
  text-align: center;
  text-transform: uppercase;
}

.blog-link {
  letter-spacing: 0.1rem;
  font-weight: bold;
}

@media (max-width: $breakpointMobile) {
  .info-container  {
    left: 0;
    width: 100%;
    height: 80vh;
  }
}

Finally, link it all together in app/components/info-panel/info-panel.js

import './info-panel.scss'
import template from './info-panel.html'
import { Component } from '../component'

/**
 * Info Panel Component
 * Download and display metadata for selected items.
 * @extends Component
 */
export class InfoPanel extends Component {
  /** LayerPanel Component Constructor
   * @param { Object } props.data.apiService ApiService instance to use for data fetching
   */
  constructor (placeholderId, props) {
    super(placeholderId, props, template)

    // Toggle info panel on title click
    this.refs.title.addEventListener('click', () => this.refs.container.classList.toggle('info-active'))
  }
}

Here, we are creating an InfoPanel class which extends our Component class.

By calling super(placeholderId, props, template), the constructor of the base Component class will be triggered. As a result, the template will be added to our main view, and we will have access to our assigned HTML elements using this.refs.

We're using three different types of import statements at the top of this file.

import './info-panel.scss' is required for Webpack to bundle the component styles with the rest of the application. We're not assigning it to a variable since we don't need to refer to this SCSS file anywhere in our component. This is refered to as importing a module for side-effects only.

import template from './info-panel.html' is taking the entire contents of layer-panel.html and assigning those contents as a string to the template variable. This is possible through the use of the html-loader Webpack module.

import { Component } from '../component' is importing the Component base class. We're using the curly braces {...} here since the Component is a named member export (as opposed to a default export) of component.js.

1.2 - Instantiate The info-panel Component in main.js

Now we'll modify main.js to instantiate our new info-panel component.

import './main.scss'
import template from './main.html'

import { InfoPanel } from './components/info-panel/info-panel'

/** Main UI Controller Class */
class ViewController {
  /** Initialize Application */
  constructor () {
    document.getElementById('app').outerHTML = template
    this.initializeComponents()
  }

  /** Initialize Components with data and event listeners */
  initializeComponents () {
    // Initialize Info Panel
    this.infoComponent = new InfoPanel('info-panel-placeholder')
  }
}

window.ctrl = new ViewController()

At the top of the main.js file, we are now importing our InfoPanel component class.

We have declared a new method within ViewController to initialize our application components, and we are now calling that method within our constructor. Within the initializeComponents method we are creating a new InfoPanel instance with "info-panel-placeholder" as the placeholderId parameter.

1.3 - Try It Out!

When we reload our app at localhost:8080, we'll see that we now have a small info tab at the bottom of the screen, which can be expanded by clicking on its title.

Blank Screen Info Panel

Great! Our simple, native Javascript component system is now in place.

Step 2 - Add Map Component

Now, finally, let's actually add the map to our application.

Create a new directory for this component - app/components/map

2.0 - Add Map Component Javascript

Add a new file to store our map component logic - app/components/map/map.js

import './map.scss'
import L from 'leaflet'
import { Component } from '../component'

const template = '<div ref="mapContainer" class="map-container"></div>'

/**
 * Leaflet Map Component
 * Render GoT map items, and provide user interactivity.
 * @extends Component
 */
export class Map extends Component {
  /** Map Component Constructor
   * @param { String } placeholderId Element ID to inflate the map into
   * @param { Object } props.events.click Map item click listener
   */
  constructor (mapPlaceholderId, props) {
    super(mapPlaceholderId, props, template)

    // Initialize Leaflet map
    this.map = L.map(this.refs.mapContainer, {
      center: [ 5, 20 ],
      zoom: 4,
      maxZoom: 8,
      minZoom: 4,
      maxBounds: [ [ 50, -30 ], [ -45, 100 ] ]
    })

    this.map.zoomControl.setPosition('bottomright') // Position zoom control
    this.layers = {} // Map layer dict (key/value = title/layer)
    this.selectedRegion = null // Store currently selected region

    // Render Carto GoT tile baselayer
    L.tileLayer(
      'https://cartocdn-gusc.global.ssl.fastly.net/ramirocartodb/api/v1/map/named/tpl_756aec63_3adb_48b6_9d14_331c6cbc47cf/all/{z}/{x}/{y}.png',
      { crs: L.CRS.EPSG4326 }).addTo(this.map)
  }
}

Here, we're initializing our leaflet map with our desired settings..

As our base tile layer, we are using an awesome "Game of Thrones" base map provided by Carto.

Note that since leaflet will handle the view rendering for us, our HTML template is so simple that we're just declaring it as a string instead of putting it in a whole separate file.

2.1 - Add Map Component SCSS

New, add the following styles to app/components/map/map.scss.

@import '../../_variables.scss';

.map-container {
  background: $lightDark;
  height: 100%;
  width: 100%;
  position: relative;
  top: 0;
  left: 0;

  /** Leaflet Style Overrides **/
  .leaflet-popup {
    bottom: 0;
  }

  .leaflet-popup-content {
    user-select: none;
    cursor: pointer;
  }

  .leaflet-popup-content-wrapper, .leaflet-popup-tip {
    background: $lightDark;
    color: $offWhite;
    text-align: center;
  }

  .leaflet-control-zoom {
    border: none;
  }

  .leaflet-control-zoom-in, .leaflet-control-zoom-out {
    background: $lightDark;
    color: $offWhite;
    border: none;
  }

  .leaflet-control-attribution {
    display: none;
  }

  @media (max-width: $breakpointMobile) {
    .leaflet-bottom {
      bottom: calc(#{$footerHeight} + 10px)
    }
  }
}

Since leaflet is rendering the core view for this component, these rules are just overriding a few default styles to give our map a distinctive look.

2.2 - Instantiate Map Component

In app/main.js add the following code to instantiate our Map component.

import { Map } from './components/map/map'
...
class ViewController {
...
  initializeComponents () {
    ...
    // Initialize Map
    this.mapComponent = new Map('map-placeholder')
  }

Don't forget to import the Map component at the top of main.js!

If you re-load the page at localhost:8080, you should now see our base leaflet map in the background.

Basemap Loaded

Awesome!

Step 3 - Display "Game Of Thrones" Data From Our API

Now that our map component is in place, let's display data from the "Game of Thrones" geospatial API that we set up in the first part of this tutorial series.

3.0 - Create API Service Class

To keep the application well-structured, we'll create a new class to wrap our API calls. It is a good practice to maintain separation-of-concerns in front-end applications by separating components (the UI controllers) from services (the data fetching and handling logic).

Create a new directory - app/services.

Now, add a new file for our API service - app/services/api.js.

import { CancelToken, get } from 'axios'

/** API Wrapper Service Class */
export class ApiService {
  constructor (url = 'http://localhost:5000/') {
    this.url = url
    this.cancelToken = CancelToken.source()
  }

  async httpGet (endpoint = '') {
    this.cancelToken.cancel('Cancelled Ongoing Request')
    this.cancelToken = CancelToken.source()
    const response = await get(`${this.url}${endpoint}`, { cancelToken: this.cancelToken.token })
    return response.data
  }

  getLocations (type) {
    return this.httpGet(`locations/${type}`)
  }

  getLocationSummary (id) {
    return this.httpGet(`locations/${id}/summary`)
  }

  getKingdoms () {
    return this.httpGet('kingdoms')
  }

  getKingdomSize (id) {
    return this.httpGet(`kingdoms/${id}/size`)
  }

  getCastleCount (id) {
    return this.httpGet(`kingdoms/${id}/castles`)
  }

  getKingdomSummary (id) {
    return this.httpGet(`kingdoms/${id}/summary`)
  }

  async getAllKingdomDetails (id) {
    return {
      kingdomSize: await this.getKingdomSize(id),
      castleCount: await this.getCastleCount(id),
      kingdomSummary: await this.getKingdomSummary(id)
    }
  }
}

This class is fairly simple. We're using Axios to make our API requests, and we are providing a method to wrap each API endpoint string.

We are using a CancelToken to ensure that we only have one outgoing request at a time. This helps to avoid network race-conditions when a user is rapidly clicking through different locations. This rapid clicking will create lots of HTTP GET requests, and can often result in the wrong data being displayed once they stop clicking.

Without the CancelToken logic, the displayed data would be that for whichever HTTP request finished last, instead of whichever location the user clicked on last. By canceling each previous request when a new request is made, we can ensure that the application is only downloading data for the currently selected location.

3.1 - Initialize API Service Class

In app/main.js, initialize the API class in the constructor.

import { ApiService } from './services/api'

...

/** Main UI Controller Class */
class ViewController {
  /** Initialize Application */
  constructor () {
    document.getElementById('app').outerHTML = template

    // Initialize API service
    if (window.location.hostname === 'localhost') {
      this.api = new ApiService('http://localhost:5000/')
    } else {
      this.api = new ApiService('https://api.atlasofthrones.com/')
    }

    this.initializeComponents()
  }

  ...
  
}

Here, we'll use localhost:5000 as our API URL if the site is being served on a localhost URL, and we'll use the hosted Atlas of Thrones API URL if not.

Don't forget to import the API service near the top of the file!

If you decided to skip part one of the tutorial, you can just instantiate the API with https://api.atlasofthrones.com/ as the default URL. Be warned, however, that this API could go offline (or have the CORs configuration adjusted to reject cross-domain requests), if it is abused or if I decide that it's costing too much to host. If you want to publicly deploy your own application using this code, please host your own backend using the instructions from part one of this tutorial. For assistance in finding affordable hosting, you could also check out an article I wrote on how to host your own applications for very cheap.

3.2 - Download GoT Location Data

Modify app/main.js with a new method to download the map data.

/** Main UI Controller Class */
class ViewController {
  /** Initialize Application */
  constructor () {
    document.getElementById('app').outerHTML = template

    // Initialize API service
    if (window.location.hostname === 'localhost') {
      this.api = new ApiService('http://localhost:5000/')
    } else {
      this.api = new ApiService('https://api.atlasofthrones.com/')
    }

    this.locationPointTypes = [ 'castle', 'city', 'town', 'ruin', 'region', 'landmark' ]
    this.initializeComponents()
    this.loadMapData()
  }

...

  /** Load map data from the API */
  async loadMapData () {
    // Download kingdom boundaries
    const kingdomsGeojson = await this.api.getKingdoms()

    // Add data to map
    this.mapComponent.addKingdomGeojson(kingdomsGeojson)
    
    // Show kingdom boundaries
    this.mapComponent.toggleLayer('kingdom')

    // Download location point geodata
    for (let locationType of this.locationPointTypes) {
      // Download GeoJSON + metadata
      const geojson = await this.api.getLocations(locationType)

      // Add data to map
      this.mapComponent.addLocationGeojson(locationType, geojson, this.getIconUrl(locationType))
      
      // Display location layer
      this.mapComponent.toggleLayer(locationType)
    }
  }

  /** Format icon URL for layer type  */
  getIconUrl (layerName) {
    return `https://cdn.patricktriest.com/atlas-of-thrones/icons/${layerName}.svg`
  }
}

In the above code, we're calling the API service to download GeoJSON data, and then we're passing this data to the map component.

We're also declaring a small helper method at the end to format a resource URL corresponding to the icon for each layer type (I'm providing the hosted icons currently, but feel free to use your own by adjusting the URL).

Notice a problem? We haven't defined any methods yet in the map component for adding GeoJSON! Let's do that now.

3.3 - Add Map Component Methods For Displaying GeoJSON Data

First, we'll add some methods to app/components/map.js in order to add map layers for the location coordinate GeoJSON (the castles, towns, villages, etc.).

export class Map extends Component {

  ...

  /** Add location geojson to the leaflet instance */
  addLocationGeojson (layerTitle, geojson, iconUrl) {
    // Initialize new geojson layer
    this.layers[layerTitle] = L.geoJSON(geojson, {
      // Show marker on location
      pointToLayer: (feature, latlng) => {
        return L.marker(latlng, {
          icon: L.icon({ iconUrl, iconSize: [ 24, 56 ] }),
          title: feature.properties.name })
      },
      onEachFeature: this.onEachLocation.bind(this)
    })
  }

  /** Assign Popup and click listener for each location point */
  onEachLocation (feature, layer) {
    // Bind popup to marker
    layer.bindPopup(feature.properties.name, { closeButton: false })
    layer.on({ click: (e) => {
      this.setHighlightedRegion(null) // Deselect highlighed region
      const { name, id, type } = feature.properties
      this.triggerEvent('locationSelected', { name, id, type })
    }})
  }
}

We are using the Leaflet helper function L.geoJSON to create new map layers using the downloaded GeoJSON data. We are then binding markers to each GeoJSON feature with L.icon, and attaching a popup to display the feature name when the icon is selected.

Next, we'll add a few methods to set up the kingdom polygon GeoJSON layer.

export class Map extends Component {

  ...

  /** Add boundary (kingdom) geojson to the leaflet instance */
  addKingdomGeojson (geojson) {
    // Initialize new geojson layer
    this.layers.kingdom = L.geoJSON(geojson, {
      // Set layer style
      style: {
        'color': '#222',
        'weight': 1,
        'opacity': 0.65
      },
      onEachFeature: this.onEachKingdom.bind(this)
    })
  }

  /** Assign click listener for each kingdom GeoJSON item  */
  onEachKingdom (feature, layer) {
    layer.on({ click: (e) => {
      const { name, id } = feature.properties
      this.map.closePopup() // Deselect selected location marker
      this.setHighlightedRegion(layer) // Highlight kingdom polygon
      this.triggerEvent('locationSelected', { name, id, type: 'kingdom' })
    }})
  }

  /** Highlight the selected region */
  setHighlightedRegion (layer) {
    // If a layer is currently selected, deselect it
    if (this.selected) { this.layers.kingdom.resetStyle(this.selected) }

    // Select the provided region layer
    this.selected = layer
    if (this.selected) {
      this.selected.bringToFront()
      this.selected.setStyle({ color: 'blue' })
    }
  }
}

We are loading the GeoJSON data the exact same way as before, using L.geoJSON. This time we are adding properties and behavior more appropriate for a region boundary polygons than individual coordinates.

export class Map extends Component {

  ...
  
  /** Toggle map layer visibility */
  toggleLayer (layerName) {
    const layer = this.layers[layerName]
    if (this.map.hasLayer(layer)) {
      this.map.removeLayer(layer)
    } else {
      this.map.addLayer(layer)
    }
  }
}

Finally, we'll declare a method to allow toggling the visibilities of individual layers. We'll be enabling all of the layers by default right now, but this toggle method will come in handy later on.

3.4 - Try it out!

Now that we're actually using the backend, we'll need to start the API server (unless you're using the hosted api.atlasofthrones URL). You can do this is a separate terminal window using npm start, or you can run the frontend file server and the Node.js in the same window using npm run dev.

Try reloading localhost:8080, and you should see the "Game Of Thrones" data added to the map.

Location Data Added

Nice!

Step 4 - Show Location Information On Click

Now let's link the map component with the info panel component in order to display information about each location when it is selected.

4.0 - Add Listener For Map Location Clicks

You might have noticed in the above code (if you were actually reading it instead of just copy/pasting), that we are calling this.triggerEvent('locationSelected', ...) whenever a map feature is selected.

This is the final useful piece of the Component class that we created earlier. Using this.triggerEvent we can trigger native Javascript window DOM events to be received by the parent component.

In app/main.js, make the following changes to the initializeComponents method.

/** Initialize Components with data and event listeners */
initializeComponents () {
  // Initialize Info Panel
  this.infoComponent = new InfoPanel('info-panel-placeholder', {
    data: { apiService: this.api }
  })

  // Initialize Map
  this.mapComponent = new Map('map-placeholder', {
    events: { locationSelected: event => {
      // Show data in infoComponent on "locationSelected" event
      const { name, id, type } = event.detail
      this.infoComponent.showInfo(name, id, type)
    }}
  })
}

We are making two important changes here.

  1. We're passing the API service as a data property to the info panel component (we'll get to this in a second).
  2. We're adding a listener for the locationSelected event, which will then trigger the info panel component's showInfo method with the location data.
4.1 - Show Location Infomation In info-panel Component

Make the following changes to app/components/info-panel/info-panel.js.

export class InfoPanel extends Component {
  constructor (placeholderId, props) {
    super(placeholderId, props, template)
    this.api = props.data.apiService

    // Toggle info panel on title click
    this.refs.title.addEventListener('click', () => this.refs.container.classList.toggle('info-active'))
  }

  /** Show info when a map item is selected */
  async showInfo (name, id, type) {
    // Display location title
    this.refs.title.innerHTML = `<h1>${name}</h1>`

    // Download and display information, based on location type
    this.refs.content.innerHTML = (type === 'kingdom')
      ? await this.getKingdomDetailHtml(id)
      : await this.getLocationDetailHtml(id, type)
  }

  /** Create kingdom detail HTML string */
  async getKingdomDetailHtml (id) {
    // Get kingdom metadata
    let { kingdomSize, castleCount, kingdomSummary } = await this.api.getAllKingdomDetails(id)

    // Convert size to an easily readable string
    kingdomSize = kingdomSize.toLocaleString(undefined, { maximumFractionDigits: 0 })

    // Format summary HTML
    const summaryHTML = this.getInfoSummaryHtml(kingdomSummary)

    // Return filled HTML template
    return `
      <h3>KINGDOM</h3>
      <div>Size Estimate - ${kingdomSize} km<sup>2</sup></div>
      <div>Number of Castles - ${castleCount}</div>
      ${summaryHTML}
      `
  }

  /** Create location detail HTML string */
  async getLocationDetailHtml (id, type) {
    // Get location metadata
    const locationInfo = await this.api.getLocationSummary(id)

    // Format summary template
    const summaryHTML = this.getInfoSummaryHtml(locationInfo)

    // Return filled HTML template
    return `
      <h3>${type.toUpperCase()}</h3>
      ${summaryHTML}`
  }

  /** Format location summary HTML template */
  getInfoSummaryHtml (info) {
    return `
      <h3>Summary</h3>
      <div>${info.summary}</div>
      <div><a href="${info.url}" target="_blank" rel="noopener">Read More...</a></div>`
  }
}

In the constructor, we are now receiving the API service from the props.data parameter, and assigning it to an instance variable.

We're also defining 4 new methods, which will work together to receive and display information on the selected location. The methods will -

  1. Call the API to retrieve metadata about the selected location.
  2. Generate HTML to display this information using Javascript ES7 template literals.
  3. Insert that HTML into the info-panel component HTML.

This is less graceful than having the data-binding capabilities of a full Javascript framework, but is still a reasonably simple way to insert HTML content into the DOM using the native Javascript browser APIs.

4.2 - Try It Out!

Now that everything is wired-up, reload the test page at localhost:8080 and try clicking on a location or kingdom. We'll see the title of that entity appear in the info-panel header, and clicking on the header will reveal the full location description.

Info Panel With Content

Sweeeeet.

Part 5 - Add Layer Panel Component

The map is pretty fun to explore now, but it's a bit crowded with all of the icons showing at once. Let's create a new component that will allow us to toggle individual map layers.

5.0 - Add Layer Panel Template and Styling

Create a new directory - app/components/layer-panel.

Add the following template to app/components/layer-panel/layer-panel.html

<div ref="panel" class="layer-panel">
  <div ref="toggle" class="layer-toggle">Layers</div>
  <div class="layer-panel-content">
    <h3>Layers</h3>
    <div ref="buttons" class="layer-buttons"></div>
  </div>
</div>

Next, add some component styling to app/components/layer-panel/layer-panel.scss

@import '../../_variables.scss';

.layer-toggle {
  display: none;
}

.layer-panel {
  position: absolute;
  top: $panelMargin;
  right: $panelMargin;
  padding: 12px;
  background: $midDark;
  z-index: 1000;
  color: $offWhite;

  h3 {
    text-align: center;
    text-transform: uppercase;
    margin: 0 auto;
  }
}

.layer-buttons {
  text-transform: uppercase;

  div {
    color: $grey;
    border-top: 1px solid $offWhite;
    padding: 6px;
    cursor: pointer;
    user-select: none;
    font-family: $fontNormal;
  }

  div.toggle-active {
    color: $offWhite;
  }

  :last-child {
    border-bottom: 1px solid $offWhite;
  }
}

@media (max-width: $breakpointMobile) {
  .layer-panel {
    display: inline-flex;
    align-items: center;
    top: 15%;
    right: 0;
    transform: translateX(calc(100% - #{$toggleLayerPanelButtonWidth}));
    transition: all 0.3s ease-in-out;
  }

  .layer-panel.layer-panel-active {
    transform: translateX(0);
  }

  .layer-toggle {
    cursor: pointer;
    display: block;
    width: $toggleLayerPanelButtonWidth;
    transform: translateY(120%) rotate(-90deg);
    padding: 10px;
    margin-left: -20px;
    letter-spacing: 1rem;
    text-transform: uppercase;
  }
}

5.1 - Add Layer Panel Behavior

Add a new file app/components/layer-panel/layer-panel.js.

import './layer-panel.scss'
import template from './layer-panel.html'
import { Component } from '../component'

/**
 * Layer Panel Component
 * Render and control layer-toggle side-panel
 */
export class LayerPanel extends Component {
  constructor (placeholderId, props) {
    super(placeholderId, props, template)

    // Toggle layer panel on click (mobile only)
    this.refs.toggle.addEventListener('click', () => this.toggleLayerPanel())

    // Add a toggle button for each layer
    props.data.layerNames.forEach((name) => this.addLayerButton(name))
  }

  /** Create and append new layer button DIV */
  addLayerButton (layerName) {
    let layerItem = document.createElement('div')
    layerItem.textContent = `${layerName}s`
    layerItem.setAttribute('ref', `${layerName}-toggle`)
    layerItem.addEventListener('click', (e) => this.toggleMapLayer(layerName))
    this.refs.buttons.appendChild(layerItem)
  }

  /** Toggle the info panel (only applies to mobile) */
  toggleLayerPanel () {
    this.refs.panel.classList.toggle('layer-panel-active')
  }

  /** Toggle map layer visibility */
  toggleMapLayer (layerName) {
    // Toggle active UI status
    this.componentElem.querySelector(`[ref=${layerName}-toggle]`).classList.toggle('toggle-active')

    // Trigger layer toggle callback
    this.triggerEvent('layerToggle', layerName)
  }
}

Here we've created a new LayerPanel component class that takes an array of layer names and renders them as a list of buttons. The component will also emit a layerToggle event whenever one of these buttons is pressed.

Easy enough so far.

5.2 - Instantiate layer-panel

In app/main.js, add the following code at the bottom of the initializeComponents method.

import { LayerPanel } from './components/layer-panel/layer-panel'

class ViewController {

  ...
  
  initializeComponents () {
    
    ...
    
    // Initialize Layer Toggle Panel
    this.layerPanel = new LayerPanel('layer-panel-placeholder', {
      data: { layerNames: ['kingdom', ...this.locationPointTypes] },
      events: { layerToggle:
        // Toggle layer in map controller on "layerToggle" event
        event => { this.mapComponent.toggleLayer(event.detail) }
      }
    })
  }

  ...
}

We are inflating the layer panel into the layer-panel-placeholder element, and we are passing in the layer names as data. When the component triggers the layerToggle event, the callback will then toggle the layer within the map component.

Don't forget to import the LayerPanel component at the top of main.js!

Note - Frontend code tends to get overly complicated when components are triggering events within sibling components. It's fine here since our app is relatively small, but be aware that passing messages/data between components through their parent component can get very messy very fast. In a larger application, it's a good idea to use a centralized state container (with strict unidirectional data flow) to reduce this complexity, such as Redux in React, Vuex in Vue, and ngrx/store in Angular.

5.3 - Sync Initial Layer Loading With Layer Panel Component

The only additional change that we should still make is in the loadMapData method of app/main.js. In this method, change each call of this.mapComponent.toggleLayer to this.layerPanel.toggleMapLayer in order to sync up the initial layer loading with our new layer panel UI component.

5.3 - Try it out!

Aaaaaand that's it. Since our map component already has the required toggleLayer method, we shouldn't need to add anything more to make our layer panel work.

Layer Panel Added

Step 6 - Add Location Search

Ok, we're almost done now, just one final component to add - the search bar.

6.0 - Client-Side Search vs. Server-Side Search

Originally, I had planned to do the search on the server-side using the string-matching functionality of our PostgreSQL database. I was even considering writing a part III tutorial on setting up a search microservice using ElasticSearch (let me know if you would still like to see a tutorial on this!).

Then I realized two things -

  1. We're already downloading all of the location titles up-front in order to render the GeoJSON.
  2. There are less than 300 total entities that we need to search through.

Given these two facts, it became apparent that this is one of those rare cases where performing the search on the client-side is actually the most appropriate option.

To perform this search, we'll add the lightweight-yet-powerful Fuse.js library to our app. As with out API operations, we'll wrap Fuse.js inside a service class to provide a layer of abstraction to our search functionality.

6.1 - Add Search Service

Before adding our search bar, we'll need to create a new service class to actually perform the search on our location data.

Add a new file app/services/search.js

import Fuse from 'fuse.js'

/** Location Search Service Class */
export class SearchService {
  constructor () {
    this.options = {
      keys: ['name'],
      shouldSort: true,
      threshold: 0.3,
      location: 0,
      distance: 100,
      maxPatternLength: 32,
      minMatchCharLength: 1
    }

    this.searchbase = []
    this.fuse = new Fuse([], this.options)
  }

  /** Add JSON items to Fuse intance searchbase
   * @param { Object[] } geojson Array geojson items to add
   * @param { String } geojson[].properties.name Name of the GeoJSON item
   * @param { String } geojson[].properties.id ID of the GeoJSON item
   * @param { String } layerName Name of the geojson map layer for the given items
  */
  addGeoJsonItems (geojson, layerName) {
    // Add items to searchbase
    this.searchbase = this.searchbase.concat(geojson.map((item) => {
      return { layerName, name: item.properties.name, id: item.properties.id }
    }))

    // Re-initialize fuse search instance
    this.fuse = new Fuse(this.searchbase, this.options)
  }

  /** Search for the provided term */
  search (term) {
    return this.fuse.search(term)
  }
}

Using this new class, we can directly pass our GeoJSON arrays to the search service in order to index the title, id, and layer name of each item. We can then simply call the search method of the class instance to perform a fuzzy-search on all of these items.

"Fuzzy-search" refers to a search that can provide inexact matches for the query string to accommodate for typos. This is very useful for location search, particularly for "Game of Thrones", since many users will only be familiar with how a location sounds (when spoken in the TV show) instead of it's precise spelling.

6.2 - Add GeoJSON data to search service

We'll modify our loadMapData() function in main.js to add the downloaded data to our search service in addition to our map.

Make the following changes to app/main.js.

import { SearchService } from './services/search'

class ViewController {

  constructor () {
    ...

    this.searchService = new SearchService()
    
    ...
  }
  
  ...

  /** Load map data from the API */
  async loadMapData () {
    // Download kingdom boundaries
    const kingdomsGeojson = await this.api.getKingdoms()

    // Add boundary data to search service
    this.searchService.addGeoJsonItems(kingdomsGeojson, 'kingdom')

    // Add data to map
    this.mapComponent.addKingdomGeojson(kingdomsGeojson)

    // Show kingdom boundaries
    this.layerPanel.toggleMapLayer('kingdom')

    // Download location point geodata
    for (let locationType of this.locationPointTypes) {
      // Download location type GeoJSON
      const geojson = await this.api.getLocations(locationType)

      // Add location data to search service
      this.searchService.addGeoJsonItems(geojson, locationType)

      // Add data to map
      this.mapComponent.addLocationGeojson(locationType, geojson, this.getIconUrl(locationType))
    }
  }
}

We are now instantiating a SearchService instance in the constructor, and calling this.searchService.addGeoJsonItems after each GeoJSON request.

Great, that wasn't too difficult. Now at the bottom of the function, you could test it out with, say, console.log(this.searchService.search('winter')) to view the search results in the browser console.

6.3 - Add Search Bar Component

Now let's actually build the search bar.

First, create a new directory - app/components/search-bar.

Add the HTML template to app/components/search-bar/search-bar.html

<div class="search-container">
  <div class="search-bar">
    <input ref="input" type="text" name="search" placeholder="Search..." class="search-input"></input>
  </div>
  <div ref="results" class="search-results"></div>
</div>

Add component styling to app/components/search-bar/search-bar.scss

@import '../../_variables.scss';

.search-container {
  position: absolute;
  top: $panelMargin;
  left: $panelMargin;
  background: transparent;
  z-index: 1000;
  color: $offWhite;
  box-sizing: border-box;
  font-family: $fontNormal;

  input[type=text] {
    width: $searchBarHeight;
    -webkit-transition: width 0.4s ease-in-out;
    transition: width 0.4s ease-in-out;
    cursor: pointer;
}

  /* When the input field gets focus, change its width to 100% */
  input[type=text]:focus {
      width: $leftPanelsWidth;
      outline: none;
      cursor: text;
  }
}

.search-results {
  margin-top: 4px;
  border-radius: 4px;
  background: $midDark;

  div {
    padding: 16px;
    cursor: pointer;
  }

  div:hover {
    background: $lightDark;
  }
}

.search-bar, .search-input {
  height: $searchBarHeight;
}

.search-input {
  background-color: $midDark;
  color: $offWhite;
  border: 3px $lightDark solid;
  border-radius: 4px;
  font-size: 1rem;
  padding: 4px;
  background-image: url('https://storage.googleapis.com/material-icons/external-assets/v4/icons/svg/ic_search_white_18px.svg');
  background-position: 20px 17px;
  background-size: 24px 24px;
  background-repeat: no-repeat;
  padding-left: $searchBarHeight;
}

@media (max-width: $breakpointMobile) {
  .search-container {
    width: 100%;
    top: 0;
    left: 0;

    .search-input {
      border-radius: 0;
    }

    .search-results {
      margin-top: 0;
      border-radius: 0;
    }
  }
}

Finally, we'll tie it all together in the component JS file - app/components/search-bar/search-bar.js

import './search-bar.scss'
import template from './search-bar.html'
import { Component } from '../component'

/**
 * Search Bar Component
 * Render and manage search-bar and search results.
 * @extends Component
 */
export class SearchBar extends Component {
  /** SearchBar Component Constructor
   * @param { Object } props.events.resultSelected Result selected event listener
   * @param { Object } props.data.searchService SearchService instance to use
   */
  constructor (placeholderId, props) {
    super(placeholderId, props, template)
    this.searchService = props.data.searchService
    this.searchDebounce = null

    // Trigger search function for new input in searchbar
    this.refs.input.addEventListener('keyup', (e) => this.onSearch(e.target.value))
  }

  /** Receive search bar input, and debounce by 500 ms */
  onSearch (value) {
    clearTimeout(this.searchDebounce)
    this.searchDebounce = setTimeout(() => this.search(value), 500)
  }

  /** Search for the input term, and display results in UI */
  search (term) {
    // Clear search results
    this.refs.results.innerHTML = ''

    // Get the top ten search results
    this.searchResults = this.searchService.search(term).slice(0, 10)

    // Display search results on UI
    this.searchResults.forEach((result) => this.displaySearchResult(result))
  }

  /** Add search result row to UI */
  displaySearchResult (searchResult) {
    let layerItem = document.createElement('div')
    layerItem.textContent = searchResult.name
    layerItem.addEventListener('click', () => this.searchResultSelected(searchResult))
    this.refs.results.appendChild(layerItem)
  }

  /** Display the selected search result  */
  searchResultSelected (searchResult) {
    // Clear search input and results
    this.refs.input.value = ''
    this.refs.results.innerHTML = ''

    // Send selected result to listeners
    this.triggerEvent('resultSelected', searchResult)
  }
}

As you can see, we're expecting the component to receive a SearchService instance as a data property, and to emit a resultSelected event.

The rest of the class is pretty straightforward. The component will listen for changes to the search input element (debounced by 500 ms) and will then search for the input term using the SearchService instance.

"Debounce" refers to the practice of waiting for a break in the input before executing an operation. Here, the component is configured to wait for a break of at least 500ms between keystrokes before performing the search. 500ms was chosen since the average computer user types at 8,000 keystrokes-per-hour, or one keystroke every 450 milliseconds. Using debounce is an important performance optimization to avoid computing new search results every time the user taps a key.

The component will then render the search results as a list in the searchResults container div and will emit the resultSelected event when a result is clicked.

6.4 - Instantiate the Search Bar Component

Now that the search bar component is built, we can simply instantiate it with the required properties in app/main.js.

import { SearchBar } from './components/search-bar/search-bar'

class ViewController {

  ...

  initializeComponents () {
    
    ...
    
    // Initialize Search Panel
    this.searchBar = new SearchBar('search-panel-placeholder', {
      data: { searchService: this.searchService },
      events: { resultSelected: event => {
        // Show result on map when selected from search results
        let searchResult = event.detail
        if (!this.mapComponent.isLayerShowing(searchResult.layerName)) {
          // Show result layer if currently hidden
          this.layerPanel.toggleMapLayer(searchResult.layerName)
        }
        this.mapComponent.selectLocation(searchResult.id, searchResult.layerName)
      }}
    })
  }
  
  ...
  
}

In the component properties, we're defining a listener for the resultSelected event. This listener will add the map layer of the selected result if it is not currently visible, and will select the location within the map component.

6.5 - Add Methods To Map Component

In the above listener, we're using two new methods in the map component - isLayerShowing and selectLocation. Let's add these methods to app/components/map.

export class Map extends Component {
  
  ...

  /** Check if layer is added to map  */
  isLayerShowing (layerName) {
    return this.map.hasLayer(this.layers[layerName])
  }

  /** Trigger "click" on layer with provided name */
  selectLocation (id, layerName) {
    // Find selected layer
    const geojsonLayer = this.layers[layerName]
    const sublayers = geojsonLayer.getLayers()
    const selectedSublayer = sublayers.find(layer => {
      return layer.feature.geometry.properties.id === id
    })

    // Zoom map to selected layer
    if (selectedSublayer.feature.geometry.type === 'Point') {
      this.map.flyTo(selectedSublayer.getLatLng(), 5)
    } else {
      this.map.flyToBounds(selectedSublayer.getBounds(), 5)
    }

    // Fire click event
    selectedSublayer.fireEvent('click')
  }
}

The isLayerShowing method simply returns a boolean representing whether the layer is currently added to the leaflet map.

The selectLocation method is slightly more complicated. It will first find the selected geographic feature by searching for a matching ID in the corresponding layer. It will then call the leaflet method flyTo (for locations) or flyToBounds (for kingdoms), in order to center the map on the selected location. Finally, it will emit the click event from the map component, in order to display the selected region's information in the info panel.

6.6 - Try it out!

The webapp is now complete! It should look like this.

Search Bar Added

Next Steps

Congrats, you've just built a frameworkless "Game of Thrones" web map!

Whew, this tutorial was a bit longer than expected.

You can view the completed webapp here - https://atlasofthrones.com/

There are lots of ways that you could build out the app from this point.

  • Polish the design and make the map beautiful.
  • Build an online, multiplayer strategy game such as Diplomacy or Risk using this codebase as a foundation.
  • Modify the application to show geo-data from your favorite fictional universe. Keep in mind that the PostGIS geography type is not limited to earth.
  • If you are a "Game of Thrones" expert (and/or if you are George R. R. Martin), use a program such as QGIS to augment the included open-source location data with your own knowledge.
  • Build a useful real-world application using civic open-data, such as this map visualizing active work-orders from recent US natural disasters such as Hurricanes Harvey and Irma.

You can find the complete open-source codebase here - https://github.com/triestpa/Atlas-Of-Thrones

Thanks for reading, feel free to comment below with any feedback, ideas, and suggestions!

Patrick Triest

Full-stack engineer, data enthusiast, insatiable learner, obsessive builder. You can find me wandering on a mountain trail, pretending not to be lost. Co-Founder, CTO @ ChipperCash.

Subscribe to Break | Better

Get the latest posts delivered right to your inbox.

or subscribe via RSS

Note - Disqus is a great commenting service, but it also embeds a lot of Javascript analytics trackers. If you want to keep your browsing data private, you can disable these trackers from your "Disqus" account settings, in the Acount -> Personalization page.

Get the latest posts delivered to your inbox.

I hate spam. I promise not to send many emails.

or subscribe via RSS