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

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.
Find bikes nearby feature in LondonCycles app.

Santander Cycles and Transport for London Unified 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
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.

{
"compilerOptions": {
"types": ["node"]
},
}
(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
npm install -g @aws-amplify/cli
amplify configure
  • Specify the AWS Region: pick-your-region
  • Specify the username of the new IAM user: amplify-london-cycles
  • 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
<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
  • 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
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
}

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

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
@import "~@aws-amplify/ui/src/Theme.css";
@import "~@aws-amplify/ui/src/Angular.css";

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.

  • 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
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" />

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

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

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).

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

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;
}
}
// 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

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.
Size and times to load BikePoint APIs response using slow and fast 3G networks.
  • 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
},
...
]
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [-0.10997, 51.529163]
},
"properties": {
"id": "BikePoints_1",
"name": "River Street , Clerkenwell"
}
]
}
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
],
}
});
}
  • 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.
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
}
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",
}]
}

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
{
"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" }
}]
}
}
}
}
}

Pushing changes to the cloud

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

amplify push
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.
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

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: ...
}

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

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.

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

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.

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.

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.

--

--

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
Gerard Sans

Gerard Sans

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