Finding the nearest locations around you using AWS Amplify — Part 3

Gerard Sans
24 min readMay 4, 2020

--

Creating a distance-aware search UI using Mapbox and Angular

This is the third and last article of a series of articles that provides a comprehensive step by step guide to enable distance-aware searches in your full-stack serverless applications using AWS Amplify and AWS AppSync.

At the end of Part 2 of this series, you created a custom GraphQL distance-aware search using AWS Amplify and AWS AppSync together with Amazon Elasticsearch. In this article, you will create the UI for LondonCycles and integrate the search query using Mapbox and Angular on the client.

We will cover:

Please let me know if you have any questions or want to learn more at @gerardsans.

LondonCycles: finding the nearest rental bikes in London using open data over GraphQL

LondonCycles is an app, for users to find available rental Santander Cycles in London. LondonCycles uses open data over GraphQL to provide the following features:

  • Display bike stations and allow querying a single bike station to see how many free bikes it has available at the moment.
  • Find the nearest bike stations around a given location and how many bikes are available for each at that time.
  • Query using users current location (Geolocation Web API) or map coordinates.
  • Allow users to locate points of interest, addresses or places using a text based search integrating the Mapbox Geocoding API.
  • Relocate map to a default location, at Piccadilly Circus, for demo purposes.

See below the first two features in action

Find bikes nearby feature in LondonCycles app.

Santander Cycles and Transport for London Unified API

Santander Cycles is a public bicycle hire scheme created in 2010 and offering more than 12,000 bikes and 778 stations in London. Popularly know as Boris bikes, after then-Mayor Boris Johnson, introduced them.

Transport for London (TfL) is the local government body responsible for the transport system in Greater London. The Unified API presents data from tube, bus, coach, river, roads and cycle hire; in a normalised structure and format via a REST API.

Step 1: setting up a new project with the Angular CLI

To get started, create a new project using the Angular CLI. If you already have it, skip to the next step. If not, install it and create the app using:

npm install -g @angular/cli
ng new amplify-london-cycles

Navigate to the new directory and check everything checks out before continuing.

cd amplify-london-cycles
npm install
ng serve

Changes to Angular CLI project

The Angular CLI requires some changes in order to use AWS Amplify. Come back to this section to troubleshoot any issues.

Add type definitions for Node.js by changing tsconfig.app.json. This is a requirement for aws-sdk-js.

{
"compilerOptions": {
"types": ["node"]
},
}

Add the following code, to the top of src/polyfills.ts.

(window as any).global = window;(window as any).process = {
env: { DEBUG: undefined }
};

AWS Amplify dependencies and credentials

Install the required dependencies for AWS Amplify and Angular using:

npm install --save aws-amplify aws-amplify-angular

In case you don’t have it already, install the Amplify CLI:

npm install -g @aws-amplify/cli

Now, configure the Amplify CLI with your credentials:

amplify configure

Once you’ve signed in to the AWS Console, continue:

  • Specify the AWS Region: pick-your-region
  • Specify the username of the new IAM user: amplify-london-cycles

In the AWS Console, click Next: Permissions, Next: Tags, Next: Review, and Create User to create your new IAM user. Then, return to the command line and press Enter.

  • Enter the access key of the newly created user:
    accessKeyId: YOUR_ACCESS_KEY_ID
    secretAccessKey: YOUR_SECRET_ACCESS_KEY
  • Profile Name: default

Setting up your Amplify environment

AWS Amplify allows you to create different environments to define your preferences and settings. For any new project, run the command below and answer as follows:

amplify init
  • Enter a name for the project: amplify-london-cycles
  • Enter a name for the environment: dev
  • Choose your default editor: Visual Studio Code
  • Please choose the type of app that you’re building javascript
  • What javascript framework are you using angular
  • Source Directory Path: src
  • Distribution Directory Path: dist/amplify-london-cycles
  • Build Command: npm run-script build
  • Start Command: ng serve
  • Do you want to use an AWS profile? Yes
  • Please choose the profile you want to use default

At this point, the Amplify CLI has initialised a new project and a new folder: amplify. The files in this folder hold your project configuration.

<amplify-app>
|_ amplify
|_ .config
|_ #current-cloud-backend
|_ backend
team-provider-info.json

Adding GraphQL LondonCyclesAPI

For LondonCycles app you are going to create a GraphQL API to store bike stations information and coordinates. To create it, use the following command:

amplify add api

Answer the following questions:

  • Please select from one of the below mentioned services GraphQL
  • Provide API name: LondonCyclesAPI
  • Choose the default authorization type for the API API key
  • Enter a description for the API key: (empty)
  • After how many days from now the API key should expire (1–365): 180
  • Do you want to configure advanced settings for the GraphQL API Yes, I want to make some additional changes.
  • Configure additional auth types? No
  • Configure conflict detection? No
  • Do you have an annotated GraphQL schema? No
  • Do you want a guided schema creation? Yes
  • What best describes your project: Single object with fields (e.g. “Todo” with ID, name, description)
  • Do you want to edit the schema now? Yes

When prompted, replace the default schema with the following:

type BikePoint @model @searchable {
id: ID!
name: String!
description: String
location: Location
bikes: Int
}
type Location {
lat: Float
lon: Float
}
input LocationInput {
lat: Float
lon: Float
}
type Query {
nearbyBikeStations(location: LocationInput!, m: Int, limit: Int)
: ModelBikePointConnection
}
type ModelBikePointConnection {
items: [BikePoint]
total: Int
nextToken: String
}

We have covered all details regarding this schema in the previous articles in this series.

Important: before going forward make sure you have followed the steps described in Part 1 and Part 2.

Pushing changes to the cloud

Run the push command to create the GraphQL API and answer as follows:

amplify push
  • Are you sure you want to continue? Yes
  • Do you want to generate code for your newly created GraphQL API Yes
  • Choose the code generation language target angular
  • Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
  • Do you want to generate/update all possible GraphQL operations — queries, mutations and subscriptions Yes
  • Enter maximum statement depth [increase from default if your schema is deeply nested] 2
  • Enter the file name for the generated code src/app/API.service.ts

Configuring the Angular application

Reference the auto-generated aws-exports.js file that is now in your src folder. To configure the app, open main.ts and add the following code below the last import:

import API from 'aws-amplify';
import amplify from './aws-exports';
API.configure(amplify);

Importing the Amplify module

Add the Amplify module and service to src/app/app.module.ts:

import { AmplifyAngularModule, AmplifyService } from 'aws-amplify-angular';@NgModule({
imports: [
AmplifyAngularModule
],
providers: [
AmplifyService
]
});

The AmplifyService provides access to AWS Amplify core categories via dependency injection: auth, analytics, storage, api, cache, pubsub; and authentication state via observables.

Adding Styling

AWS Amplify provides UI components that you can use in your app. Let’s add these components to the project

npm install --save @aws-amplify/ui

Also include these imports to the top of styles.css

@import "~@aws-amplify/ui/src/Theme.css";
@import "~@aws-amplify/ui/src/Angular.css";

These are all the steps necessary to setup your project. Let’s see how you can integrate it with Mapbox.

Step 2: adding Mapbox to your App

Introduction to Mapbox

Mapbox is an American provider of custom online maps and creator of Mapbox GL-JS, a JavaScript library leveraging WebGL to render interactive maps using vector tiles.

These are the main advantages of using Mapbox over other alternatives:

  • Quick setup and generous free tier (see appendix).
  • Snappy User Experience and smooth zoom for Web and Mobile.
  • Rendering vector tiles is faster than rendering traditional raster tiles.
  • Vector maps size is highly reduced.

Adding Mapbox

To add Mapbox GL JS to your project run the following command

npm install --save mapbox-gl @types/mapbox-gl

Create a Mapbox account, then go to tokens page. Copy your default public token and save it into src/environments/environment.ts as below.

export const environment = {
production: false,
mapBoxToken: '<YOUR-DEFAULT-PUBLIC-TOKEN>'
};

Add mapbox styles to src/index.html

<link href="https://api.tiles.mapbox.com/mapbox-gl-js/v1.4.1/mapbox-gl.css" rel="stylesheet" />

LondonCycles UI design

The User Interface will be a map taking most of the screen but the top where we will place a header. These will be two separate components: app-header and app-map .

Header (top) and map (below) components.

Creating header and map components

Generate these components using Angular CLI as follows:

ng generate component components/header
ng generate component components/map

Let’s look atsrc/app/components/map/map.component.htmland related styles

<div id="map" class="map-container"></div>.map-container {
width: 100%;
height: calc(100% - 50px);
position: absolute;
}

Adding the id attribute will allow Mapbox to locate and render itself using a canvas element and a div element used as a placeholder. The map will take all screen but the header which is 50px.

For src/app/components/map/map.component.ts we are using a map service to initialise Mapbox by calling a helper method buildMap.

import { Component, OnInit } from '@angular/core';
import { MapService } from 'services/map.service';
@Component({
selector: 'app-map',
templateUrl: './map.component.html',
styleUrls: ['./map.component.css']
})
export class MapComponent implements OnInit {
constructor(private map: MapService) { }
ngOnInit() {
this.map.buildMap();
}
}

Creating the map service

We will implement all our mapbox features into a service. Run the following command to generate the map service

ng generate service services/map

Add to src/app/services/map.service.ts the following code

import { environment } from "@env/environment";
import * as mapboxgl from "mapbox-gl";
@Injectable({ providedIn: "root" })
export class MapService {
mapbox = mapboxgl as typeof mapboxgl;
map: mapboxgl.Map;
constructor(private api: APIService) {
this.mapbox.accessToken = environment.mapBoxToken;
}
buildMap() {
this.map = new mapboxgl.Map({
container: "map",
style: 'mapbox://styles/mapbox/light-v10',
zoom: 14,
center: [-0.134167, 51.510239] // Piccadilly Circus
});
this.addMapControls();
}
}

First, we are setting a public map property using Mapbox types. Then, in our constructor, we are injecting our API service to run searches and setup the Mapbox accesTokenusing the environment object. This will link your Mapbox account to this app usage. To initialise the map, we are setting the containerthat should match the id we used previously in our map component. We are using Mapbox light styles and tile sets with initial zoom and coordinates. Finally, we added the map controls.

Adding Mapbox Controls

Mapbox comes with a set of built-in controls. For LondonCycles, we used some of those and added a few custom ones to enhance User Experience. See all controls, for quick reference, below with custom controls greyed out (2, 3 and 7).

Note how we adapted the default styles to use round buttons (below) to improve mobile experience.

LondonCycles UI controls (from Mapbox and custom)

These are the built-in Mapbox controls we are using:

  • Navigation control; zooms the map in and out. The third button, introduces a 3D perspective by clicking on it and holding. After you use it, you can click again to return to its original settings. Label 1.
  • Geolocate control; displays the user’s live location in the map as a blue circle. User has to grant access to share its location. Label 4.
  • Scale control; displays a rule and its units as for the current map view and zoom level. Label 5.
  • Geocoder control; allows users to search for popular landmarks including neighbourhoods, addresses and points of interest. Label 6.

Let’s see how the code looks like for these

addMapControls() {
// Navigation buttons group (label #1)
this.map.addControl(new mapboxgl.NavigationControl(), 'top-left');

// Geolocate button (label #4)
this.geolocate = new mapboxgl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true
});
this.geolocate.on('geolocate', (e) => {
this.userLocation = [e.coords.longitude, e.coords.latitude];
});
this.map.addControl(this.geolocate, 'top-left');
// Scale control (label #5)
this.map.addControl(new mapboxgl.ScaleControl());
}

Setup is fairly straightforward, we added these controls to the map using the addControl API and informed where the controls are positioned. For the geolocate control, we set high accuracy and user location tracking. This is because, we want the user location to be updated as it changes over time. We added an event handler to do so with every geolocate event.

In order to add the geocoder control, we need to add a dependency and its own CSS styles. This will enable users, new to London, to search for addresses, popular landmarks and points of interest. See a full example for more details.

import * as MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import * as mapboxgl from "mapbox-gl";
export class MapService {
mapbox = mapboxgl as typeof mapboxgl;
addMapControls() {
...
// Geocoder search input (label #6)
this.map.addControl(new MapboxGeocoder({
accessToken: this.mapbox.accessToken,
zoom: 14,
placeholder: 'Enter search',
mapboxgl: this.mapbox
}));
}
}

As part of the configuration, we set the zoom level we want to display the results and its placeholder. Note that it also requires the Mapbox instance.

The geocoder control requires your access token as there’s a limit of 600 request per minute per account.

Adding custom Mapbox controls

Mapbox allows adding custom controls to your map by implementing the IControl interface. This must include an HTML element, usually a div styled with the mapboxgl-controlclass; and the onAdd and onRemove methods. Find below a simplified version.

export class MapboxGLButtonControl {
_map; _container; _title; _eventHandler;
constructor({ title = "", eventHandler }) {
this._title = title;
this._eventHandler = eventHandler;
}
onAdd(map) {
this._map = map;
const btn = document.createElement('button');
btn.onclick = this._eventHandler;
btn.title = this._title;
this._container = document.createElement('div');
this._container.className = 'mapboxgl-ctrl';
this._container.appendChild(btn);
return container;
}

onRemove() {
this._container.parentNode.removeChild(this._container);
this._map = undefined;
}
}

Adding custom controls is then reduced to creating a new instance and passing its configuration to the map using addControl as we did before.

// Reset button (label #2)
const customReset = new MapboxGLButtonControl({
title: 'Reset Location',
eventHandler: this.flyToStart.bind(this)
});
this.map.addControl(customReset, 'top-left');

From the code above, notice how we are passing the event handler for each button using bind to set the right thiscontext.

Let’s look at the implementation of customReset button. We will use this button to reset the map around Piccadilly Circus for demo purposes. We set coordinates, a comfortable zoom level and timing using flyTo available from the map instance.

flyToStart() {
this.map.flyTo({
center: [-0.134167, 51.510239], // Piccadilly Circus
zoom: 14,
speed: 0.5
});
}

We will look at the specific implementation of the remaining buttons separately as these require a bit more code.

Step 3: displaying bike stations

One of the main features of LondonCycles is the ability to display bike stations available in London over the map. There’s few considerations before implementing this feature regarding the volume of this dataset. We know that there are 778 stations and we have few APIs available from Transport for London. We are going to cache this information into a file and load it as part of the map initialisation to boost performance.

Data access strategies and design

These are the APIs for Santander Cycles:

  • /BikePoint, get all bike stations status including id, description, coordinates, capacity and available bikes.
  • /BikePoint/id, get a bike station status by id.
  • /BikePoint/search, query all bike stations by name.

For our solution, we will focus on the first two APIs. See below the size and response load time for BikePoint APIs using slow and fast 3G networks.

Size and times to load BikePoint APIs response using slow and fast 3G networks.

In order to provide the nearest bike stations along with their current availability we need to split our search in two stages:

  • Stage 1: search the nearest bike stations around a given location. Bike stations coordinates data (778 bike stations) is provided ad-hoc to avoid having to request this data on-demand due to its volume (1.7 MB). Done in Part 2, Amazon Elasticsearch will return the results within few milliseconds.
  • Stage 2: query each bike station via Unified API for current available bikes. We will use the bike stations ids returned in the previous stage to retrieve current available bikes. This data will be provided on-demand using an AWS AppSync custom HTTP resolver (0.6 KB). We will cover this in a later section.

Transforming bike stations data

Mapbox provides different data sources we can use to display information in a map. We are going to use a GeoJSON data source to display Santander Cycles bike stations. This means that we need to transform the data from the Unified API below to GeoJSON format. You can try it live here.

## /BikePoints API response
[
{
"id": "BikePoints_1",
"commonName": "River Street, Clerkenwell",
"additionalProperties": [{
"key": "NbBikes", "value": "11",
}],
"lat": 51.529163,
"lon": -0.10997
},
...
]

GeoJSON is an open standard format designed to represent geographical features, along with other metadata using Javascript Object Notation (JSON). Features include points (coordinates), lines, polygons, and collections of these.

This is how we will translate the result from /BikePoints to GeoJSON. The root object will be a feature collection containing an array of features. Then, we will translate each bike station into a point feature, and adding its id and name as metadata under properties.

{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-0.10997, 51.529163]
},
"properties": {
"id": "BikePoints_1",
"name": "River Street , Clerkenwell"
}
]
}

You can use geojson.io (screenshot below), an online editor, to familiarise with GeoJSON and preview your geolocation data.

As we explained, we will create a new file assets/bikes.geojson to keep this data and load it into the map.

buildMap() {
...
this.map.on('load', () => {
this.map.addSource('bike-stations', {
type: 'geojson',
data: 'assets/bikes.geojson'
});
}
}

Mapbox separates data sources (sources) from styling (layers). See below an overview of sources and layers for LondonCycles. We set out the Mapbox light tiles styles while creating the map.

Overview of Mapbox sources and layers for LondonCycles app.

Once a Mapbox source is loaded, we can set up its styling by using layers. In our implementation we will use the customBikesToggle button to call toggleBikes and setup the styling for that layer.

addMapControls() {
...
// Bikes toggle button (label #3)
const customBikesToggle = new MapboxGLButtonControl({
title: 'Toggle bikes',
eventHandler: this.toggleBikes.bind(this)
});
this.map.addControl(customBikesToggle, 'top-left');
...
}
toggleBikes() {
// hide layer if visible
if (this.map.getLayer('bike-stations')) {
this.map.removeLayer('bike-stations');
return;
}
this.map.addLayer({
id: 'bike-stations',
type: 'circle',
source: 'bikes',
paint: {
'circle-color': 'rgba(190, 190, 190, 0.62)',
'circle-radius' : [
'interpolate', ['linear'], ['zoom'], 8, 1, 11, 4, 14, 15
],
}
});
}

Note that this button acts as a toggle, so if bike-stations layer is visible we will hide it; otherwise, we will display the bike stations layer. This layer will show bike stations coordinates as circles. We set paint styles to specify color and size (radius):

  • circle-color; we used rgba for the color to allow a certain degree of transparency and overlapping.
  • circle-radius; we used an interpolate expression to change the radius according to zoom levels. Values are processed by pairs and interpolated using a linear function otherwise. Eg: first pair, for zoom level 8 or less, will use acircle-radiusof 1; last pair, for zoom level 14 or more, will use acircle-radiusof 15.

See below the difference between using an interpolation expression on the left and a fixed radius on the right.

Using interpolation to change circle sizes at different zoom levels.

Step 4: creating a custom AWS AppSync Http resolver

We will now create a custom AWS AppSync Http resolver. This resolver will be associated to the bikesfield in the BikePoint type. See below:

type BikePoint @model @searchable {
id: ID!
name: String!
description: String
location: Location
bikes: Int
}

This will allow us to use GraphQL to retrieve the live status for a bike station from the Unified API and seamlessly aggregate it as part of the results for any queries involving the BikePoint type.

For an introduction to AWS AppSync resolvers you can read AWS AppSync Velocity Templates Guide.

The way this resolver works is by mapping an incoming GraphQL request including the field bikes in the BikePoint type into a Http request, and then mapping the response back to the field bikes as part of the GraphQL response like shown in the diagram below.

GraphQL query flow with a field resolver mapped to a Http request

In Step 1, when a client runs a GraphQL query it will create a request to the GraphQL API. Following in Step 2, AWS AppSync will process any query including the field bikes in the BikePoint type and trigger the resolver request template and run it against /BikePoint/id from the Unified API. In Step 3, AWS AppSync will process the response and run the corresponding resolver response template. Finally in Step 4, AWS AppSync will send back to the client the result of the resolver response template in the form of a value for the field bikes and aggregate it to the GraphQL response JSON object.

To create a custom AWS AppSync Http resolver we need to create both a request and a response templates. These will be part of your api. Let’s see where these resources are in your project.

/amplify/backend/api/
TransportForLondonAPI/resolvers
BikePoint.bikes.req.vtl
BikePoint.bikes.res.vtl

Let’s start by looking to an example of a query. For this example, we want to search a bike station by id.

query getBikePoint {
getBikePoint(id: "BikePoints_1") {
id
name
bikes
}
}

Once we run this query AWS AppSync will process it and run the getBikePoint resolver followed by the new Http resolver on the bikes field. The result will then be aggregated to the rest of fields and sent back to the client as follows

{
"data": {
"getBikePoint": {
"id": "BikePoints_1",
"name": "River Street , Clerkenwell",
"bikes": 4
}
}
}

Let’s start by looking at the resolver request template for the bikes field below.

## BikePoint.bikes.req.vtl{
"version": "2018-05-29",
"method": "GET",
"resourcePath": "/BikePoint/$context.source.id",
}

This template maps calls to /BikePoint/id from the Unified API. In order to provide the id we will access the $context object and in particular the source map that contains the result for the parent query, the getBikePoint resolver.

Our response template below takes care of the responses from the Unified API.

## BikePoint.bikes.res.vtl#set($body = $util.parseJson($ctx.result.body))
#if($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
#if($ctx.result.statusCode == 200)
$body.additionalProperties[6].value
#else
#return
#end

First, we parse the result and deal with error handling. If the response has no errors, it returns the bikes available or null otherwise. See below an example of a JSON response. You can try it live here.

## /BikePoints/id - JSON response (simplified){
"id": "BikePoints_1",
"commonName": "River Street, Clerkenwell",
"additionalProperties": [{
"key": "NbBikes", "value": "11",
}]
}

Adding the Http resolver to your project

Using Amplify we can add a custom AWS AppSync resolver by adding its templates to our project. These will be part of CustomResources.jsonin your apistack.

/amplify/backend/api/
TransportForLondonAPI/stacks
CustomResources.json

Add the resolver templates to the Resourcessection as below naming each highlighted entry accordingly.

{
"Resources": {
"HttpDataSource": {
"Type": "AWS::AppSync::DataSource",
"Properties": {
"ApiId": { "Ref": "AppSyncApiId" },
"Name": "TransportForLondonAPI",
"Type": "HTTP",
"HttpConfig": { "Endpoint": "https://api.tfl.gov.uk/" }
}
},
"BikePointBikes": {
"Type": "AWS::AppSync::Resolver",
"Properties": {
"ApiId": { "Ref": "AppSyncApiId" },
"DataSourceName": "TransportForLondonAPI",
"TypeName": "BikePoint",
"FieldName": "bikes",
"RequestMappingTemplateS3Location": {
"Fn::Sub": [ "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/BikePoint.bikes.req.vtl", {
"S3DeploymentBucket": { "Ref": "S3DeploymentBucket" },
"S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }
}]
},
"ResponseMappingTemplateS3Location": {
"Fn::Sub": [
"s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/BikePoint.bikes.res.vtl", {
"S3DeploymentBucket": { "Ref": "S3DeploymentBucket" },
"S3DeploymentRootKey": { "Ref": "S3DeploymentRootKey" }
}]
}
}
}
}
}

Take special care to make sure template file names and paths match the ones in your project and resources entries don’t conflict with any other resources you already have.

Pushing changes to the cloud

Run the following command to push all changes to the cloud:

amplify push

Once the command finishes, you can try a query including the bikes field using amplify console api from the command line and selecting GraphQL.

query getBikePoint {
getBikePoint(id: "BikePoints_1") { id name bikes }
}

Step 5: querying a specific bike station on the map

In previous sections, we added a button to display stations in the map. Now we are going to add a new feature which will allow users to query a specific bike station. By doing this, users will be able to check how many bikes are available at that moment using the AWS AppSync Http resolver we just created. See the user interaction below.

User checking bikes available around Piccadilly Circus.

The way Mapbox allows this kind of interaction is by adding an event handler to the bike-stations layer. Once the user clicks, we are able to access the GeoJSON data source as part of the event and use the id we added as part of the metadata properties to query our GraphQL API. Let’s see the code below.

toggleBikes() {
...
this.addPopUpToLayer('bike-stations');
}
addPopUpToLayer(layer) {
if (!this.map.getLayer(layer)) return;
this.map.on('click', layer, e => {
const feature: GeoJSON.Feature = e.features[0];
const geometry: any = feature.geometry;
const coordinates = geometry.coordinates.slice();
this.getBikePoint(feature.properties.id, coordinates);
});

// User experience. Change cursor when going over a bike station
this.map.on('mouseenter', layer, () => {
this.map.getCanvas().style.cursor = 'pointer';
});
this.map.on('mouseleave', layer, () => {
this.map.getCanvas().style.cursor = '';
});
}

Most of the code is dealing with finding out the underlying GeoJSON data from the event object. Let’s see again the parts we are interested in:

// Bike station GeoJSON feature
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-0.10997, 51.529163]
},
"properties": {
"id": "BikePoints_1",
"name": "River Street , Clerkenwell"
}
}

At this point, we are ready to call the GraphQL API and render a popup to show: bike station name and available bikes. To access the GetBikePoint API generated by AWS AppSync we can use dependency injection as follows.

import { APIService } from '../../API.service';@Injectable({ providedIn: "root" })
export class MapService {
constructor(private api: APIService) { }
}

As for the call to GetBikePoint API this is the implementation

getBikePoint(id, coordinates) {
// center map using bike station coordinates
this.map.flyTo({ center: coordinates, speed: 0.25 });
// call GraphQL api to get live data using the HTTP resolver
this.api.GetBikePoint(id).then( data => {
const bikePoint = { ...data };
// remove any existing popups
this.popups.forEach(popup => popup.remove());
// store popup reference for later use
this.popups.push(new mapboxgl.Popup({ offset: [0, -18] })
.setLngLat(coordinates)
.setHTML(this.renderPopup(bikePoint))
.addTo(this.map));
})
}
renderPopup(bikePoint): string {
return `<strong>${bikePoint.name}</strong>
<div>${bikePoint.bikes || '0'} bikes available.</div>`;
}

From the code above, we first animate the map to center around the bike station the user clicked on to provide some immediate feedback. Then we query our GraphQL API using GetBikePoint generated by AWS AppSync passing the bike station id and wait for the results. As results arrive, we create a Mapbox popup which we store so we can keep only one popup open at any time. We use a slight vertical offset and display the information on the map.

This kind of manual interaction is fine to check a specific bike station but it can become cumbersome. In order to improve this, we will add a new option to search the nearest bike stations around the user in just one go.

Step 6: finding bikes nearby and displaying results

Finally, we are ready to implement the distance-aware search query we created in Part 2. Let’s see how the query looks like again:

type Query {   
nearbyBikeStations(location: LocationInput!, m: Int, limit: Int): [BikePoint]
}

Loading bike stations coordinates data

As we saw in Part 1, after adding @searchable to BikePoint type, every time we run a GraphQL mutation (create, update or delete) it will be indexed in Amazon Elasticsearch. So, for each bike station, we will run a mutation that will therefore provide the coordinates required to run the distance-aware search queries.

mutation addBikePoint {
m1: createBikePoint(input: {
id: "BikePoints_1"
name: "River Street , Clerkenwell"
location: {
lat: "51.529163"
lon: "-0.10997"
}) { id }
...
m777: ...
}

You can find the whole script at src/assets/mutations.bikes.gql.

Implementing Find bikes custom control

Let’s look at the code involved for adding the custom control, running the query and displaying the results. See the code to create the customFindBikes button followed by the method to run the GraphQL search query below.

addMapControls() {
...
// Find bikes button (label #7)
const customFindBikes = new MapboxGLButtonControl({
title: 'Find bikes here',
eventHandler: this.findBikes.bind(this)
});
this.map.addControl(customFindBikes, 'top-right');
}
findBikes() {
this.clearSourceData(['search']); // reset source data
const center = this.map.getCenter();
const mapCenter = { lon: center.lng, lat: center.lat };

// zoom and center
this.map.flyTo({ center, zoom: 14 });
// find nearby bike stations
this.api.NearbyBikeStations(mapCenter).then(result => {
this.searchResults = {
"type": "FeatureCollection",
"features": []
}
result.items.forEach(p => this.addPointToSearchResults(p));
this.setSourceData('search', this.searchResults);
});
}
setSourceData(source, data){
const s = this.map.getSource(source) as mapboxgl.GeoJSONSource;
s && s.setData(data);
}
clearSourceData(sources) {
sources.forEach(source => {
this.setSourceData(source, {
'type': 'FeatureCollection', 'features': []
});
});
}

In order to run the distance-aware search query, we will use the current map center coordinates. This will match users live location if geolocation is enabled. We are using clearSourceData and setSourceData helper functions to clear and set search results. Once we get the results, we will create a GeoJSON source to display them. In order to build this data structure, we transform each bike station into a GeoJSON feature collection and render it.

addToSearchResults(bikePoint) {
this.searchResults.features.push({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [bikePoint.location.lon, bikePoint.location.lat]
},
"properties": {
"id": bikePoint.id,
"name": bikePoint.name,
"bikes": bikePoint.bikes

}
})
}

As we did previously, we are going to use a Mapbox source and layer to hold our coordinates data and style it on the map. We added the following:

buildMap() {
...
this.map.addSource('search', {
type: 'geojson', data: this.emptyGeoJSON
});
this.map.addLayer({
'id': 'search-results', 'type': 'circle', 'source': "search",
'paint': {
'circle-radius': [ 'interpolate', ['linear'], ['zoom'],
8, 1, 11, 4, 14, 15 ],

'circle-color': '#FF9900',
}
});
this.map.addLayer({
'id': 'search-labels', 'type': 'symbol', 'source': 'search',
'layout': {
'text-field': '{bikes}',
'text-font': ['DIN Offc Pro Medium','Arial Unicode MS Bold'],
'text-size': ['interpolate', ['linear'], ['zoom'],
8, 1, 11, 6, 14, 12 ]

},
paint: { 'text-color': '#333' }
});
}

To display results, we are going to use two layers: one to draw a circle around the coordinates and another to display available bikes. Note how we included interpolation expressions to adjust circle and labels sizes to zoom levels. For labels we used a Mapbox symbol layer.

Mapbox symbol layers can also render icons besides text labels.

We render available bikes which we included at feature.properties.bikes using the layout settings text-field and interpolating its value {bikes}. This is a common strategy to display GeoJSON feature properties in a map. See the resulting user interaction below.

Adding visual feedback

Search is working as expected and showing results but we want to improve the User Experience by adding some feedback while waiting for results. We will show a message and show the range for the search. This range is set to 500 meters around the users location.

Let’s see how we can achieve this by using some mathematics and what we already know. Just before we call the search, we are going to display a few new layers and hide them once the results arrive.

findBikes() {
...
this.renderSearchRange(mapCenterGeoJSON);

// find nearby bike stations
this.api.NearbyBikeStations(mapCenter).then(...);
}

We are going to use two sources: one to display the search range and the other to display a Searching... label. We added these to the map configuration.

buildMap() {
...
this.map.addSource('searching-range', {
type: 'geojson', data: this.emptyGeoJSON
});
this.map.addLayer({
'id': 'searching-range', 'type': 'fill',
'source': 'searching-range',
'paint': { 'fill-color': 'rgba(202, 210, 211,0.3)' }
});
this.map.addSource('searching-label', {
type: 'geojson', data: this.emptyGeoJSON
});
this.map.addLayer({
id: 'searching-label', type: 'symbol',
source: 'searching-label',
layout: {
'text-field': 'Searching...',
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': ['interpolate', ['linear'], ['zoom'],
8, 1, 11, 6, 14, 12 ]
},
'paint': { 'text-color': '#333' }
});
}

Let’s see the code for the renderSearchRange method

renderSearchRange(location) {
this.map.jumpTo({ 'center': location, 'zoom': 14 });
// render search range
const searchRange = this.createGeoJSONRange(location);
this.setSourceData('searching-range', searchRange);
// render 'searching...' label
const searchRangeLocation = {
'type': 'FeatureCollection',
'features': [{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [location.lon, location.lat]
}
}]
};
this.setSourceData('searching-label', searchRangeLocation);
}

First, we make sure we are in a zoom level that shows all results. Let’s look into the method to render the search range next

createGeoJSONRange(center, m = 500, points = 60): any {
let polygon = [];
const rangeX = m/(111320*Math.cos(center.lat*Math.PI/180));
const rangeY = m/110574;
let theta, x, y;

for (var i = 0; i < points; i++) {
theta = (i/points) * (2*Math.PI);
x = rangeX * Math.cos(theta);
y = rangeY * Math.sin(theta);
polygon.push([center.lon+x, center.lat+y]);
}
polygon.push(polygon[0]); // close polygon
return {
'type': 'FeatureCollection',
'features': [{
'type': 'Feature',
'geometry': {
'type': 'Polygon',
'coordinates': [polygon]
}
}]
}
}

We are creating a GeoJSON feature polygon that is an approximation of a circumference around the center coordinates. Default arguments for distance are 500 meters and 60 points. See below a diagram showing different points approximations you may consider.

Circle polygon approximations and points used.

Conclusion

Congratulations! You have finished the whole series. In this article you have learnt how to create LondonCycles a client UI using Mapbox and Angular to seamlessly integrate a GraphQL distance-aware search query with Santander Cycles open data. LondonCycles uses AWS Amplify and AWS AppSync together with Amazon Elasticsearch to allow users to find the nearest bikes while visiting London.

Final solution is available at https://github.com/gsans/amplify-london-cycles. Remember to star this project if it helped you. Thanks!

Ready to code?

You don’t have an AWS Account? Use the next few minutes to create one and activate the free plan for a whole year. Follow steps at AWS knowledge center.

Once your free plan expires you are charged only for instance hours, Amazon EBS storage (if you choose this option), and data transfer.

Free tier for a new AWS Account. Check out latest pricing.
Free tier for Mapbox GL JS. Check out latest pricing.

Thanks for reading!

Have you got any questions regarding this article AWS Amplify or AWS AppSync? Feel free to ping me anytime at @gerardsans.

My Name is Gerard Sans. I am a Developer Advocate at AWS Mobile working with AWS Amplify and AWS AppSync teams.

Santander Cycles is a public bicycle hire scheme in London.

Transport for London (TfL) is a local government body responsible for the transport system in Greater London, England.

GraphQL is an open-source data query and manipulation language for APIs.

Mapbox is an American provider of custom online maps.

Angular is an open source project from Google.

--

--

Gerard Sans
Gerard Sans

Written by Gerard Sans

Helping Devs to succeed #AI #web3 / ex @AWSCloud / Just be AWSome / MC Speaker Trainer Community Leader @web3_london / @ReactEurope @ReactiveConf @ngcruise

No responses yet