Finding the nearest locations around you using AWS Amplify — Part 3
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:
- Part 1: enable distance-aware searches using @searchable
- Part 2: create a custom distance-aware search using AWS AppSync
- Part 3: create UI using Mapbox and Angular (this article)
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
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
.
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.html
and 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 accesToken
using the environment
object. This will link your Mapbox account to this app usage. To initialise the map, we are setting the container
that 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.
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-control
class; 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 this
context.
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.
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.
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 usedrgba
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-radius
of 1; last pair, for zoom level 14 or more, will use acircle-radius
of 15.
See below the difference between using an interpolation expression on the left and a fixed radius on the right.
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 bikes
field 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.
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.json
in your api
stack.
/amplify/backend/api/
TransportForLondonAPI/stacks
CustomResources.json
Add the resolver templates to the Resources
section 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.
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.
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.
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.