Geo Queries with PostGIS in Ionic Angular

2023-03-01

32 minute read

Does your app need to handle geo data like latitude, longitude, or distance between geographic locations?

Then Supabase got you covered again as you can unlock all of this with the PostGIS extension!

In this tutorial you will learn to:

Since there are quite some code snippets we need I've put together the full source code on Github so you can easily run the project yourself!

Ready for some action?

Let's start within Supabase.

Creating the Supabase Project

To get started we need a new Supabase project. If you don't have a Supabase account yet, you can get started for free!

In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy o your Database password!

After a minute your project should be ready, and we can configure our tables and extensions with SQL.

Why PostGIS Extension?

Why do we actually need the PostGIS extension for our Postgres database?

Turns out storing lat/long coordinates and querying them isn't very effective and doesn't scale well.

By enabling this extension, we get access to additional data types like Point or Polygon, and we can easily add an index to our data that makes retrieving locations within certain bounds super simpler.

It's super easy to use PostGIS with Supabase as we just need to enable the extension - which is just one of many other Postgres extensions that you can toggle on with just a click!

Defining your Tables with SQL

Adding the PostGIS Extensions

We could enable PostGIS from the Supabase project UI but we can actually do it with SQL as well, so let's navigate to the SQL Editor from the menu and run the following:


_10
-- enable the PostGIS extension
_10
create extension postgis with schema extensions;

You can now find this and many other extensions under Database -> Extensions:

It's as easy as that, and we can now create the rest of our table structure.

Creating the SQL Tables

For our example, we need one Stores table so we can add stores with some text and their location.

Additionally, we create a spartial index on the location of our store to make our queries more performant.

Finally, we can also create a new storage bucket for file upload, so go ahead and run the following in the SQL Editor:


_19
-- create our table
_19
create table if not exists public.stores (
_19
id int generated by default as identity primary key,
_19
name text not null,
_19
description text,
_19
location geography(POINT) not null
_19
);
_19
_19
-- add the spatial index
_19
create index stores_geo_index
_19
on public.stores
_19
using GIST (location);
_19
_19
-- create a storage bucket and allow file upload/download
_19
insert into storage.buckets (id, name)
_19
values ('stores', 'stores');
_19
_19
CREATE POLICY "Select images" ON storage.objects FOR SELECT TO public USING (bucket_id = 'stores');
_19
CREATE POLICY "Upload images" ON storage.objects FOR INSERT TO public WITH CHECK (bucket_id = 'stores');

For our tests, I also added some dummy data. Feel free to use mine or use coordinates closer to you:


_12
-- add some dummy data
_12
insert into public.stores
_12
(name, description, location)
_12
values
_12
(
_12
'The Galaxies.dev Shop',
_12
'Galaxies.dev - your favourite place to learn',
_12
st_point(7.6005702, 51.8807174)
_12
),
_12
('The Local Dev', 'Local people, always best', st_point(7.614454, 51.876565)),
_12
('City Store', 'Get the supplies a dev needs', st_point(7.642581, 51.945606)),
_12
('MEGA Store', 'Everything you need', st_point(13.404315, 52.511640));

To wrap this up we define 2 database functions:

  • nearby_stores will return a list of all stores and their distance to a lat/long place
  • stores_in_view uses more functions like ST_MakeBox2D to find all locations in a specific box of coordinates

Those are some powerful calculations, and we can easily use them through the PostGIS extension and by defining database functions like this:


_20
-- create database function to find nearby stores
_20
create or replace function nearby_stores(lat float, long float)
_20
returns table (id public.stores.id%TYPE, name public.stores.name%TYPE, description public.stores.description%TYPE, lat float, long float, dist_meters float)
_20
language sql
_20
as $$
_20
select id, name, description, st_y(location::geometry) as lat, st_x(location::geometry) as long, st_distance(location, st_point(long, lat)::geography) as dist_meters
_20
from public.stores
_20
order by location <-> st_point(long, lat)::geography;
_20
$$;
_20
_20
_20
-- create database function to find stores in a specific box
_20
create or replace function stores_in_view(min_lat float, min_long float, max_lat float, max_long float)
_20
returns table (id public.stores.id%TYPE, name public.stores.name%TYPE, lat float, long float)
_20
language sql
_20
as $$
_20
select id, name, ST_Y(location::geometry) as lat, ST_X(location::geometry) as long
_20
from public.stores
_20
where location && ST_SetSRID(ST_MakeBox2D(ST_Point(min_long, min_lat), ST_Point(max_long, max_lat)),4326)
_20
$$;

With all of that in place we are ready to build a powerful app with geo-queries based on our Supabase geolocation data!

Working with Geo Queries in Ionic Angular

Setting up the Project

We are not bound to any framework, but in this article, we are using Ionic Angular to build a cross-platform application.

Additionally we use Capacitor to include a native Google Maps component and to retrieve the user location.

Get started by bringing up a new Ionic project, then add two pages and a service and run the first build so we can generate the native platforms with Capacitor.

Finally we can install the Supabase JS package, so go ahead and run:


_22
ionic start supaMap blank --type=angular
_22
cd ./supaMap
_22
_22
ionic g page store
_22
ionic g page nearby
_22
ionic g service services/stores
_22
_22
ionic build
_22
ionic cap add ios
_22
ionic cap add android
_22
_22
_22
# Add Maps and Geolocation plugins
_22
npm install @capacitor/google-maps
_22
npm install @capacitor/geolocation
_22
_22
# Install Supabase
_22
npm install @supabase/supabase-js
_22
_22
# Ionic 7 wasn't released so I installed the next version
_22
# not required if you are already on Ionic 7
_22
npm install @ionic/core@next @ionic/angular@next

Within the new project we need to add our Supabase credentials and a key for the Google Maps API to the src/environments/environment.ts like this:


_10
export const environment = {
_10
production: false,
_10
mapsKey: 'YOUR-GOOGLE-MAPS-KEY',
_10
supabaseUrl: 'YOUR-URL',
_10
supabaseKey: 'YOUR-ANON-KEY',
_10
}

You can find those values in your Supabase project by clicking on the Settings icon and then navigating to API where it shows your Project API keys.

The Google Maps API key can be obtained from the Google Cloud Platform where you can add a new project and then create credentials for the Maps Javascript API.

Native Project Configuration

To use the Capacitor plugin we also need to update the permissions of our native projects, so within the ios/App/App/Info.plist we need to include these:


_10
<key>NSLocationAlwaysUsageDescription</key>
_10
<string>We want to show your nearby places</string>
_10
<key>NSLocationWhenInUseUsageDescription</key>
_10
<string>We want to show your nearby places</string>
_10
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
_10
<string>To show your location</string>

Additionally, we need to add our Maps Key to the android/app/src/main/AndroidManifest.xml:


_10
<meta-data android:name="com.google.android.geo.API_KEY" android:value="YOUR_API_KEY_HERE"/>

Finally also add the required permissions for Android in the android/app/src/main/AndroidManifest.xml at the bottom:


_10
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
_10
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
_10
<uses-feature android:name="android.hardware.location.gps" />

You can also find more information about using Capacitor maps with Ionic in my Ionic Academy!

Finding Nearby Places with Database Functions

Now the fun begins, and we can start by adding a function to our src/app/services/stores.service.ts that calls the database function (Remote Procedure Call) that we defined in the beginning:


_40
import { Injectable } from '@angular/core'
_40
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'
_40
import { SupabaseClient, User, createClient } from '@supabase/supabase-js'
_40
import { environment } from 'src/environments/environment'
_40
_40
export interface StoreEntry {
_40
lat?: number
_40
long?: number
_40
name: string
_40
description: string
_40
image?: File
_40
}
_40
export interface StoreResult {
_40
id: number
_40
lat: number
_40
long: number
_40
name: string
_40
description: string
_40
image?: SafeUrl
_40
dist_meters?: number
_40
}
_40
@Injectable({
_40
providedIn: 'root',
_40
})
_40
export class StoresService {
_40
private supabase: SupabaseClient
_40
_40
constructor(private sanitizer: DomSanitizer) {
_40
this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey)
_40
}
_40
_40
// Get all places with calculated distance
_40
async getNearbyStores(lat: number, long: number) {
_40
const { data, error } = await this.supabase.rpc('nearby_stores', {
_40
lat,
_40
long,
_40
})
_40
return data
_40
}
_40
}

This should return a nice list of StoreResult items that we can render in a list.

For that, let's display a modal from our src/app/home/home.page.ts:


_24
import { Component } from '@angular/core'
_24
import { ModalController } from '@ionic/angular'
_24
import { NearbyPage } from '../nearby/nearby.page'
_24
_24
export interface StoreMarker {
_24
markerId: string
_24
storeId: number
_24
}
_24
_24
@Component({
_24
selector: 'app-home',
_24
templateUrl: 'home.page.html',
_24
styleUrls: ['home.page.scss'],
_24
})
_24
export class HomePage {
_24
constructor(private modalCtrl: ModalController) {}
_24
_24
async showNearby() {
_24
const modal = await this.modalCtrl.create({
_24
component: NearbyPage,
_24
})
_24
modal.present()
_24
}
_24
}

We also need a button to present that modal, so change the src/app/home/home.page.html to include one:


_13
<ion-header>
_13
<ion-toolbar color="primary">
_13
<ion-buttons slot="start">
_13
<ion-button (click)="showNearby()">
_13
<ion-icon name="location" slot="start"></ion-icon> Nearby</ion-button
_13
>
_13
</ion-buttons>
_13
_13
<ion-title> Supa Stores </ion-title>
_13
</ion-toolbar>
_13
</ion-header>
_13
_13
<ion-content> </ion-content>

Now we are able to use the getNearbyStores from our service on that modal page, and we also load the current user location using Capacitor.

Once we got the user coordinates, we pass them to our function and PostGIS will do its magic to calculate the distance between us and the stores of our database!

Go ahead and change the src/app/nearby/nearby.page.ts to this now:


_38
import { Component, OnInit } from '@angular/core'
_38
import { Geolocation } from '@capacitor/geolocation'
_38
import { StoresService, StoreResult } from '../services/stores.service'
_38
import { LoadingController, ModalController } from '@ionic/angular'
_38
_38
@Component({
_38
selector: 'app-nearby',
_38
templateUrl: './nearby.page.html',
_38
styleUrls: ['./nearby.page.scss'],
_38
})
_38
export class NearbyPage implements OnInit {
_38
stores: StoreResult[] = []
_38
_38
constructor(
_38
private storesService: StoresService,
_38
public modalCtrl: ModalController,
_38
private loadingCtrl: LoadingController
_38
) {}
_38
_38
async ngOnInit() {
_38
// Show loading while getting data from Supabase
_38
const loading = await this.loadingCtrl.create({
_38
message: 'Loading nearby places...',
_38
})
_38
loading.present()
_38
_38
const coordinates = await Geolocation.getCurrentPosition()
_38
_38
if (coordinates) {
_38
// Get nearby places sorted by distance using PostGIS
_38
this.stores = await this.storesService.getNearbyStores(
_38
coordinates.coords.latitude,
_38
coordinates.coords.longitude
_38
)
_38
loading.dismiss()
_38
}
_38
}
_38
}

At this point, you can already log the values, but we can also quickly display them in a nice list by updating the src/app/nearby/nearby.page.html to:


_22
<ion-header>
_22
<ion-toolbar color="primary">
_22
<ion-buttons slot="start">
_22
<ion-button (click)="modalCtrl.dismiss()">
_22
<ion-icon slot="icon-only" name="close"></ion-icon>
_22
</ion-button>
_22
</ion-buttons>
_22
<ion-title>Nearby Places</ion-title>
_22
</ion-toolbar>
_22
</ion-header>
_22
_22
<ion-content>
_22
<ion-list>
_22
<ion-item *ngFor="let store of stores">
_22
<ion-label>
_22
{{ store.name }}
_22
<p>{{store.description }}</p>
_22
</ion-label>
_22
<ion-note slot="end">{{store.dist_meters!/1000 | number:'1.0-2' }} km</ion-note>
_22
</ion-item>
_22
</ion-list>
_22
</ion-content>

If you open the modal, you should now see a list like this after your position was loaded:

It looks so easy - but so many things are already coming together at this point:

  • Capacitor geolocation inside the browser
  • Supabase RPC to a stored database function
  • PostGIS geolocation calculation

We will see more of this powerful extension soon, but let's quickly add another modal to add our own data.

Add Stores with Coordinates to Supabase

To add data to Supabase we create a new function in our src/app/services/stores.service.ts:


_19
async addStore(info: StoreEntry) {
_19
// Add a new database entry using the POINT() syntax for the coordinates
_19
const { data } = await this.supabase
_19
.from('stores')
_19
.insert({
_19
name: info.name,
_19
description: info.description,
_19
location: `POINT(${info.long} ${info.lat})`,
_19
})
_19
.select()
_19
.single();
_19
_19
if (data && info.image) {
_19
// Upload the image to Supabase
_19
const foo = await this.supabase.storage
_19
.from('stores')
_19
.upload(`/images/${data.id}.png`, info.image);
_19
}
_19
}

Notice how we convert the lat/long information of an entry to a string.

This is how PostGIS expects those values!

We use our Supabase storage bucket to upload an image file if it's included in the new StoreEntry. It's almost too easy and feels like cheating to upload a file to cloud storage in just three lines...

Now we need a simple modal, so just like before we add a new function to the src/app/home/home.page.ts:


_10
async addStore() {
_10
const modal = await this.modalCtrl.create({
_10
component: StorePage,
_10
});
_10
modal.present();
_10
}

That function get's called from another button in our src/app/home/home.page.html:


_16
<ion-header>
_16
<ion-toolbar color="primary">
_16
<ion-buttons slot="start">
_16
<ion-button (click)="showNearby()">
_16
<ion-icon name="location" slot="start"></ion-icon> Nearby</ion-button
_16
>
_16
</ion-buttons>
_16
_16
<ion-title> Supa Stores </ion-title>
_16
<ion-buttons slot="end">
_16
<ion-button (click)="addStore()">
_16
<ion-icon name="add" slot="start"></ion-icon> Store</ion-button
_16
>
_16
</ion-buttons>
_16
</ion-toolbar>
_16
</ion-header>

Back in this new modal, we will define an empty StoreEntry object and then connect it to the input fields in our view.

Because we defined the rest of the functionality in our service, we can simply update the src/app/store/store.page.ts to:


_34
import { Component, OnInit } from '@angular/core'
_34
import { ModalController } from '@ionic/angular'
_34
import { StoreEntry, StoresService } from '../services/stores.service'
_34
_34
@Component({
_34
selector: 'app-store',
_34
templateUrl: './store.page.html',
_34
styleUrls: ['./store.page.scss'],
_34
})
_34
export class StorePage implements OnInit {
_34
store: StoreEntry = {
_34
name: '',
_34
description: '',
_34
image: undefined,
_34
lat: undefined,
_34
long: undefined,
_34
}
_34
_34
constructor(
_34
public modalCtrl: ModalController,
_34
private storesService: StoresService
_34
) {}
_34
_34
ngOnInit() {}
_34
_34
imageSelected(ev: any) {
_34
this.store.image = ev.detail.event.target.files[0]
_34
}
_34
_34
async addStore() {
_34
this.storesService.addStore(this.store)
_34
this.modalCtrl.dismiss()
_34
}
_34
}

The view is not really special and simply holds a bunch of input fields that are connected to the new store entry, so bring up the src/app/store/store.page.html and change it to:


_41
<ion-header>
_41
<ion-toolbar color="primary">
_41
<ion-buttons slot="start">
_41
<ion-button (click)="modalCtrl.dismiss()">
_41
<ion-icon slot="icon-only" name="close"></ion-icon>
_41
</ion-button>
_41
</ion-buttons>
_41
<ion-title>Add Store</ion-title>
_41
</ion-toolbar>
_41
</ion-header>
_41
_41
<ion-content class="ion-padding">
_41
<ion-input
_41
label="Store name"
_41
label-placement="stacked"
_41
placeholder="Joeys"
_41
[(ngModel)]="store.name"
_41
/>
_41
<ion-textarea
_41
rows="3"
_41
label="Store description"
_41
label-placement="stacked"
_41
placeholder="Some about text"
_41
[(ngModel)]="store.description"
_41
/>
_41
<ion-input type="number" label="Latitude" label-placement="stacked" [(ngModel)]="store.lat" />
_41
<ion-input type="number" label="Longitude" label-placement="stacked" [(ngModel)]="store.long" />
_41
<ion-input
_41
label="Select store image"
_41
(ionChange)="imageSelected($event)"
_41
type="file"
_41
accept="image/*"
_41
></ion-input>
_41
_41
<ion-button
_41
expand="full"
_41
(click)="addStore()"
_41
[disabled]="!store.lat || !store.long || store.name === ''"
_41
>Add Store</ion-button
_41
>
_41
</ion-content>

As a result, you should have a clean input modal:

Give your storage inserter a try and add some places around you - they should be available in your nearby list immediately!

Working with Google Maps and Marker

Adding a Map

Now we have some challenges ahead: adding a map, loading data, and creating markers.

But if you've come this far, I'm sure you can do it!

Get started by adding the CUSTOM_ELEMENTS_SCHEMA to the src/app/home/home.module.ts which is required to use Capacitor native maps:


_15
import { NgModule } from '@angular/core'
_15
import { CommonModule } from '@angular/common'
_15
import { IonicModule } from '@ionic/angular'
_15
import { FormsModule } from '@angular/forms'
_15
import { HomePage } from './home.page'
_15
_15
import { HomePageRoutingModule } from './home-routing.module'
_15
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
_15
_15
@NgModule({
_15
imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],
_15
declarations: [HomePage],
_15
schemas: [CUSTOM_ELEMENTS_SCHEMA],
_15
})
_15
export class HomePageModule {}

In our src/app/home/home.page.ts we can now create the map by passing in a reference to a DOM element and some initial settings for the map and of course your key.

Update the page with our first step that adds some new variables:


_68
import { Component, ElementRef, ViewChild } from '@angular/core'
_68
import { GoogleMap } from '@capacitor/google-maps'
_68
import { LatLngBounds } from '@capacitor/google-maps/dist/typings/definitions'
_68
import { ModalController } from '@ionic/angular'
_68
import { BehaviorSubject } from 'rxjs'
_68
import { environment } from 'src/environments/environment'
_68
import { NearbyPage } from '../nearby/nearby.page'
_68
import { StoreResult, StoresService } from '../services/stores.service'
_68
import { StorePage } from '../store/store.page'
_68
_68
export interface StoreMarker {
_68
markerId: string
_68
storeId: number
_68
}
_68
_68
@Component({
_68
selector: 'app-home',
_68
templateUrl: 'home.page.html',
_68
styleUrls: ['home.page.scss'],
_68
})
_68
export class HomePage {
_68
@ViewChild('map') mapRef!: ElementRef<HTMLElement>
_68
map!: GoogleMap
_68
mapBounds = new BehaviorSubject<LatLngBounds | null>(null)
_68
activeMarkers: StoreMarker[] = []
_68
selectedMarker: StoreMarker | null = null
_68
selectedStore: StoreResult | null = null
_68
_68
constructor(
_68
private storesService: StoresService,
_68
private modalCtrl: ModalController
_68
) {}
_68
_68
ionViewDidEnter() {
_68
this.createMap()
_68
}
_68
_68
async createMap() {
_68
this.map = await GoogleMap.create({
_68
forceCreate: true, // Prevent issues with live reload
_68
id: 'my-map',
_68
element: this.mapRef.nativeElement,
_68
apiKey: environment.mapsKey,
_68
config: {
_68
center: {
_68
lat: 51.8,
_68
lng: 7.6,
_68
},
_68
zoom: 7,
_68
},
_68
})
_68
this.map.enableCurrentLocation(true)
_68
}
_68
_68
async showNearby() {
_68
const modal = await this.modalCtrl.create({
_68
component: NearbyPage,
_68
})
_68
modal.present()
_68
}
_68
_68
async addStore() {
_68
const modal = await this.modalCtrl.create({
_68
component: StorePage,
_68
})
_68
modal.present()
_68
}
_68
}

The map needs a place to render, so we can now add it to our src/app/home/home.page.html and wrap it in a div to add some additional styling later:


_22
<ion-header>
_22
<ion-toolbar color="primary">
_22
<ion-buttons slot="start">
_22
<ion-button (click)="showNearby()">
_22
<ion-icon name="location" slot="start"></ion-icon> Nearby</ion-button
_22
>
_22
</ion-buttons>
_22
_22
<ion-title> Supa Stores </ion-title>
_22
<ion-buttons slot="end">
_22
<ion-button (click)="addStore()">
_22
<ion-icon name="add" slot="start"></ion-icon> Store</ion-button
_22
>
_22
</ion-buttons>
_22
</ion-toolbar>
_22
</ion-header>
_22
_22
<ion-content>
_22
<div class="container">
_22
<capacitor-google-map #map></capacitor-google-map>
_22
</div>
_22
</ion-content>

Because the Capacitor map essentially renders behind your webview inside a native app, we need to make the background of our current page invisible.

For this, simply add the following to the src/app/home/home.page.scss:


_14
ion-content {
_14
--background: none;
_14
}
_14
_14
.container {
_14
width: 100%;
_14
height: 100%;
_14
}
_14
_14
capacitor-google-map {
_14
display: inline-block;
_14
width: 100%;
_14
height: 100%;
_14
}

Now the map should fill the whole screen.

This brings us to the last missing piece…

Loading Places in a Box of Coordinates

Getting all stores is usually too much - you want to show what's nearby to a user, and you can do this by sending basically a box of coordinates to our previously stored database function.

For this, we first add another call in our src/app/services/stores.service.ts:


_15
// Get all places in a box of coordinates
_15
async getStoresInView(
_15
min_lat: number,
_15
min_long: number,
_15
max_lat: number,
_15
max_long: number
_15
) {
_15
const { data } = await this.supabase.rpc('stores_in_view', {
_15
min_lat,
_15
min_long,
_15
max_lat,
_15
max_long,
_15
});
_15
return data;
_15
}

Nothing fancy, just passing those values to the database function.

The challenging part is now listening to map boundary updates, which happen whenever you slightly touch the list.

Because we don't want to call our function 100 times in one second, we use a bit of RxJS to delay the update of our coordinates so the updateStoresInView function is called after the user finished swiping the list.

At that point, we grab the map bounds and call our function, so go ahead and update the src/app/home/home.page.ts with the following:


_52
async createMap() {
_52
this.map = await GoogleMap.create({
_52
forceCreate: true, // Prevent issues with live reload
_52
id: 'my-map',
_52
element: this.mapRef.nativeElement,
_52
apiKey: environment.mapsKey,
_52
config: {
_52
center: {
_52
lat: 51.8,
_52
lng: 7.6,
_52
},
_52
zoom: 7,
_52
},
_52
});
_52
this.map.enableCurrentLocation(true);
_52
_52
// Listen to biew changes and emit to our Behavior Subject
_52
this.map.setOnBoundsChangedListener((ev) => {
_52
this.mapBounds.next(ev.bounds);
_52
});
_52
_52
// React to changes of our subject with a 300ms delay so we don't trigger a reload all the time
_52
this.mapBounds.pipe(debounce((i) => interval(300))).subscribe((res) => {
_52
this.updateStoresInView();
_52
});
_52
_52
// Get the current user coordinates
_52
this.loadUserLocation();
_52
}
_52
_52
async updateStoresInView() {
_52
const bounds = await this.map.getMapBounds();
_52
_52
// Get stores in our bounds using PostGIS
_52
const stores = await this.storesService.getStoresInView(
_52
bounds.southwest.lat,
_52
bounds.southwest.lng,
_52
bounds.northeast.lat,
_52
bounds.northeast.lng
_52
);
_52
_52
// Update markers for elements
_52
this.addMarkers(stores);
_52
}
_52
_52
async loadUserLocation() {
_52
// TODO
_52
}
_52
_52
async addMarkers(stores: StoreResult[]) {
_52
// TODO
_52
}

We can also fill one of our functions with some code as we already used the Geolocation plugin to load users' coordinates before, so update the function to:


_15
async loadUserLocation() {
_15
// Get location with Capacitor Geolocation plugin
_15
const coordinates = await Geolocation.getCurrentPosition();
_15
_15
if (coordinates) {
_15
// Focus the map on user and zoom in
_15
this.map.setCamera({
_15
coordinate: {
_15
lat: coordinates.coords.latitude,
_15
lng: coordinates.coords.longitude,
_15
},
_15
zoom: 14,
_15
});
_15
}
_15
}

Now we are loading the user location and zooming in to the current place, which will then cause our updateStoresInView function to be triggered and we receive a list of places that we just need to render!

Displaying Marker on our Google Map

You can already play around with the app and log the stores after moving the map - it really feels magical how PostGIS returns only the elements that are within the box of coordinates.

To actually display them we can add the following function to our src/app/home/home.page.ts now:


_45
async addMarkers(stores: StoreResult[]) {
_45
// Skip if there are no results
_45
if (stores.length === 0) {
_45
return;
_45
}
_45
_45
// Find marker that are outside of the view
_45
const toRemove = this.activeMarkers.filter((marker) => {
_45
const exists = stores.find((item) => item.id === marker.storeId);
_45
return !exists;
_45
});
_45
_45
// Remove markers
_45
if (toRemove.length) {
_45
await this.map.removeMarkers(toRemove.map((marker) => marker.markerId));
_45
}
_45
_45
// Create new marker array
_45
const markers: Marker[] = stores.map((store) => {
_45
return {
_45
coordinate: {
_45
lat: store.lat,
_45
lng: store.long,
_45
},
_45
title: store.name,
_45
};
_45
});
_45
_45
// Add markers, store IDs
_45
const newMarkerIds = await this.map.addMarkers(markers);
_45
_45
// Crate active markers by combining information
_45
this.activeMarkers = stores.map((store, index) => {
_45
return {
_45
markerId: newMarkerIds[index],
_45
storeId: store.id,
_45
};
_45
});
_45
_45
this.addMarkerClicks();
_45
}
_45
_45
addMarkerClicks() {
_45
// TODO
_45
}

This function got a bit longer because we need to manage our marker information. If we just remove and repaint all markers, it looks and feels horrible so we always keep track of existing markers and only render new markers.

Additionally, these Marker have limited information, and if we click a marker we want to present a modal with information about the store from Supabase.

That means we also need the real ID of that object, and so we create an array activeMarkers that basically connects the information of a store ID with the marker ID!

At this point, you should be able to see markers on your map. If you can't see them, zoom out and you might find them.

To wrap this up, let's take a look at one more cool Supabase feature.

Presenting Marker with Image Transform

We have the marker and store ID, so we can simply load the information from our Supabase database.

Now a store might have an image, and while we download the image from our storage bucket we can use image transformations to get an image exactly in the right dimensions to save time and bandwidth!

For this, add two new functions to our src/app/services/stores.service.ts:


_21
// Load data from Supabase database
_21
async loadStoreInformation(id: number) {
_21
const { data } = await this.supabase
_21
.from('stores')
_21
.select('*')
_21
.match({ id })
_21
.single();
_21
return data;
_21
}
_21
_21
async getStoreImage(id: number) {
_21
// Get image for a store and transform it automatically!
_21
return this.supabase.storage
_21
.from('stores')
_21
.getPublicUrl(`images/${id}.png`, {
_21
transform: {
_21
width: 300,
_21
resize: 'contain',
_21
},
_21
}).data.publicUrl;
_21
}

To use image transformations we only need to add an object to the getPublicUrl() function and define the different properties we want to have.

Again, it's that easy.

Now we just need to load this information when we click on a marker, so add the following function to our src/app/home/home.page.ts which handles the click on a map marker:


_25
addMarkerClicks() {
_25
// Handle marker clicks
_25
this.map.setOnMarkerClickListener(async (marker) => {
_25
// Find our local object based on the marker ID
_25
const info = this.activeMarkers.filter(
_25
(item) => item.markerId === marker.markerId.toString()
_25
);
_25
if (info.length) {
_25
this.selectedMarker = info[0];
_25
_25
// Load the store information from Supabase Database
_25
this.selectedStore = await this.storesService.loadStoreInformation(
_25
info[0].storeId
_25
);
_25
_25
// Get the iamge from Supabase Storage
_25
const img = await this.storesService.getStoreImage(
_25
this.selectedStore!.id
_25
);
_25
if (img) {
_25
this.selectedStore!.image = img;
_25
}
_25
}
_25
});
_25
}

We simply load the information and image and set this to our selectedStore variable.

This will now be used to trigger an inline modal, so we don't need to come up with another component and can simply define our Ionic modal right inside the src/app/home/home.page.html like this:


_41
<ion-header>
_41
<ion-toolbar color="primary">
_41
<ion-buttons slot="start">
_41
<ion-button (click)="showNearby()">
_41
<ion-icon name="location" slot="start"></ion-icon> Nearby</ion-button
_41
>
_41
</ion-buttons>
_41
_41
<ion-title> Supa Stores </ion-title>
_41
<ion-buttons slot="end">
_41
<ion-button (click)="addStore()">
_41
<ion-icon name="add" slot="start"></ion-icon> Store</ion-button
_41
>
_41
</ion-buttons>
_41
</ion-toolbar>
_41
</ion-header>
_41
_41
<ion-content>
_41
<div class="container">
_41
<capacitor-google-map #map></capacitor-google-map>
_41
</div>
_41
_41
<ion-modal
_41
[isOpen]="selectedMarker !== null"
_41
[breakpoints]="[0, 0.4, 1]"
_41
[initialBreakpoint]="0.4"
_41
(didDismiss)="selectedMarker = null;"
_41
>
_41
<ng-template>
_41
<ion-content class="ion-padding">
_41
<ion-label class="ion-texst-wrap">
_41
<h1>{{selectedStore?.name}}</h1>
_41
<ion-note>{{selectedStore?.description}}</ion-note>
_41
</ion-label>
_41
<div class="ion-text-center ion-margin-top">
_41
<img [src]="selectedStore?.image" *ngIf="selectedStore?.image" />
_41
</div>
_41
</ion-content>
_41
</ng-template>
_41
</ion-modal>
_41
</ion-content>

Because we also used breakpoints and the initialBreakpoint properties of the modal we get this nice bottom sheet modal UI whenever we click on a marker:

And with that, we have finished our Ionic app with Supabase geo-queries using PostGIS!

Conclusion

I was fascinated by the power of this simple PostGIS extension that we enabled with just one command (or click).

Building apps based on geolocation data is a very common scenario, and with PostGIS we can build these applications easily on the back of a Supabase database (and auth ), and storage, and so much more..)

You can find the full code of this tutorial on Github where you just need to insert your own Supabase instance. your Google Maps key and then create the tables with the included SQL file.

If you enjoyed the tutorial, you can find many more tutorials and courses on Galaxies.dev where I help modern web and mobile developers build epic apps 🚀

Until next time and happy coding with Supabase!

Share this article

Build in a weekend, scale to millions