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

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

Find bikes nearby feature in LondonCycles app.

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

npm install -g @angular/cli
ng new amplify-london-cycles
cd amplify-london-cycles
npm install
ng serve
{
"compilerOptions": {
"types": ["node"]
},
}
(window as any).global = window;(window as any).process = {
env: { DEBUG: undefined }
};
npm install --save aws-amplify aws-amplify-angular
npm install -g @aws-amplify/cli
amplify configure
amplify init
<amplify-app>
|_ amplify
|_ .config
|_ #current-cloud-backend
|_ backend
team-provider-info.json
amplify add api
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
}

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

amplify push
import API from 'aws-amplify';
import amplify from './aws-exports';
API.configure(amplify);
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.

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

Step 2: adding Mapbox to your App

npm install --save mapbox-gl @types/mapbox-gl
export const environment = {
production: false,
mapBoxToken: '<YOUR-DEFAULT-PUBLIC-TOKEN>'
};
<link href="https://api.tiles.mapbox.com/mapbox-gl-js/v1.4.1/mapbox-gl.css" rel="stylesheet" />
Header (top) and map (below) components.
ng generate component components/header
ng generate component components/map
<div id="map" class="map-container"></div>.map-container {
width: 100%;
height: calc(100% - 50px);
position: absolute;
}
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();
}
}
ng generate service services/map
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();
}
}

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

LondonCycles UI controls (from Mapbox and custom)
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());
}
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
}));
}
}

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

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;
}
}
// Reset button (label #2)
const customReset = new MapboxGLButtonControl({
title: 'Reset Location',
eventHandler: this.flyToStart.bind(this)
});
this.map.addControl(customReset, 'top-left');
flyToStart() {
this.map.flyTo({
center: [-0.134167, 51.510239], // Piccadilly Circus
zoom: 14,
speed: 0.5
});
}

Step 3: displaying bike stations

Size and times to load BikePoint APIs response using slow and fast 3G networks.
## /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.

{
"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.

buildMap() {
...
this.map.on('load', () => {
this.map.addSource('bike-stations', {
type: 'geojson',
data: 'assets/bikes.geojson'
});
}
}
Overview of Mapbox sources and layers for LondonCycles app.
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
],
}
});
}
Using interpolation to change circle sizes at different zoom levels.

Step 4: creating a custom AWS AppSync Http resolver

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

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

GraphQL query flow with a field resolver mapped to a Http request
/amplify/backend/api/
TransportForLondonAPI/resolvers
BikePoint.bikes.req.vtl
BikePoint.bikes.res.vtl
query getBikePoint {
getBikePoint(id: "BikePoints_1") {
id
name
bikes
}
}
{
"data": {
"getBikePoint": {
"id": "BikePoints_1",
"name": "River Street , Clerkenwell",
"bikes": 4
}
}
}
## BikePoint.bikes.req.vtl{
"version": "2018-05-29",
"method": "GET",
"resourcePath": "/BikePoint/$context.source.id",
}
## 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
## /BikePoints/id - JSON response (simplified){
"id": "BikePoints_1",
"commonName": "River Street, Clerkenwell",
"additionalProperties": [{
"key": "NbBikes", "value": "11",
}]
}
/amplify/backend/api/
TransportForLondonAPI/stacks
CustomResources.json
{
"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.

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

Step 5: querying a specific bike station on the map

User checking bikes available around Piccadilly Circus.
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 = '';
});
}
// Bike station GeoJSON feature
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-0.10997, 51.529163]
},
"properties": {
"id": "BikePoints_1",
"name": "River Street , Clerkenwell"
}
}
import { APIService } from '../../API.service';@Injectable({ providedIn: "root" })
export class MapService {
constructor(private api: APIService) { }
}
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>`;
}

Step 6: finding bikes nearby and displaying results

type Query {   
nearbyBikeStations(location: LocationInput!, m: Int, limit: Int): [BikePoint]
}
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.

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': []
});
});
}
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

}
})
}
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' }
});
}

Mapbox symbol layers can also render icons besides text labels.

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

// find nearby bike stations
this.api.NearbyBikeStations(mapCenter).then(...);
}
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' }
});
}
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);
}
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]
}
}]
}
}
Circle polygon approximations and points used.

Conclusion

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?

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

Thanks for reading!

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.

--

--

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

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store