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:
- Enable and use PostGIS extension with Supabase
- Store and retrieve geolocation data
- Use database functions for geo-queries with PostGIS
- Display a Capacitor Google Map with a marker
- Upload files to Supabase Storage and use image transformations
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_10create 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_19create 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_19create index stores_geo_index_19 on public.stores_19 using GIST (location);_19_19-- create a storage bucket and allow file upload/download_19insert into storage.buckets (id, name)_19values ('stores', 'stores');_19_19CREATE POLICY "Select images" ON storage.objects FOR SELECT TO public USING (bucket_id = 'stores');_19CREATE 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_12insert into public.stores_12 (name, description, location)_12values_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 placestores_in_view
uses more functions likeST_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_20create or replace function nearby_stores(lat float, long float)_20returns setof record_20language sql_20as $$_20 select id, name, description, st_astext(location) as location, 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_20create or replace function stores_in_view(min_lat float, min_long float, max_lat float, max_long float)_20returns setof record_20language sql_20as $$_20 select id, name, ST_Y(location::geometry) as lat, ST_X(location::geometry) as long, st_astext(location) as location_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:
_22ionic start supaMap blank --type=angular_22cd ./supaMap_22_22ionic g page store_22ionic g page nearby_22ionic g service services/stores_22_22ionic build_22ionic cap add ios_22ionic cap add android_22_22_22# Add Maps and Geolocation plugins_22npm install @capacitor/google-maps_22npm install @capacitor/geolocation_22_22# Install Supabase_22npm 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_22npm 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:
_10export 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:
_40import { Injectable } from '@angular/core'_40import { DomSanitizer, SafeUrl } from '@angular/platform-browser'_40import { SupabaseClient, User, createClient } from '@supabase/supabase-js'_40import { environment } from 'src/environments/environment'_40_40export interface StoreEntry {_40 lat?: number_40 long?: number_40 name: string_40 description: string_40 image?: File_40}_40export 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})_40export 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:
_24import { Component } from '@angular/core'_24import { ModalController } from '@ionic/angular'_24import { NearbyPage } from '../nearby/nearby.page'_24_24export 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})_24export 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:
_38import { Component, OnInit } from '@angular/core'_38import { Geolocation } from '@capacitor/geolocation'_38import { StoresService, StoreResult } from '../services/stores.service'_38import { LoadingController, ModalController } from '@ionic/angular'_38_38@Component({_38 selector: 'app-nearby',_38 templateUrl: './nearby.page.html',_38 styleUrls: ['./nearby.page.scss'],_38})_38export 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:
_31import { Component, OnInit } from '@angular/core'_31import { ModalController } from '@ionic/angular'_31import { StoreEntry, StoresService } from '../services/stores.service'_31_31@Component({_31 selector: 'app-store',_31 templateUrl: './store.page.html',_31 styleUrls: ['./store.page.scss'],_31})_31export class StorePage implements OnInit {_31 store: StoreEntry = {_31 name: '',_31 description: '',_31 image: undefined,_31 lat: undefined,_31 long: undefined,_31 }_31_31 constructor(public modalCtrl: ModalController, private storesService: StoresService) {}_31_31 ngOnInit() {}_31_31 imageSelected(ev: any) {_31 this.store.image = ev.detail.event.target.files[0]_31 }_31_31 async addStore() {_31 this.storesService.addStore(this.store)_31 this.modalCtrl.dismiss()_31 }_31}
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:
_15import { NgModule } from '@angular/core'_15import { CommonModule } from '@angular/common'_15import { IonicModule } from '@ionic/angular'_15import { FormsModule } from '@angular/forms'_15import { HomePage } from './home.page'_15_15import { HomePageRoutingModule } from './home-routing.module'_15import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'_15_15@NgModule({_15 imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],_15 declarations: [HomePage],_15 schemas: [CUSTOM_ELEMENTS_SCHEMA],_15})_15export 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:
_65import { Component, ElementRef, ViewChild } from '@angular/core'_65import { GoogleMap } from '@capacitor/google-maps'_65import { LatLngBounds } from '@capacitor/google-maps/dist/typings/definitions'_65import { ModalController } from '@ionic/angular'_65import { BehaviorSubject } from 'rxjs'_65import { environment } from 'src/environments/environment'_65import { NearbyPage } from '../nearby/nearby.page'_65import { StoreResult, StoresService } from '../services/stores.service'_65import { StorePage } from '../store/store.page'_65_65export interface StoreMarker {_65 markerId: string_65 storeId: number_65}_65_65@Component({_65 selector: 'app-home',_65 templateUrl: 'home.page.html',_65 styleUrls: ['home.page.scss'],_65})_65export class HomePage {_65 @ViewChild('map') mapRef!: ElementRef<HTMLElement>_65 map!: GoogleMap_65 mapBounds = new BehaviorSubject<LatLngBounds | null>(null)_65 activeMarkers: StoreMarker[] = []_65 selectedMarker: StoreMarker | null = null_65 selectedStore: StoreResult | null = null_65_65 constructor(private storesService: StoresService, private modalCtrl: ModalController) {}_65_65 ionViewDidEnter() {_65 this.createMap()_65 }_65_65 async createMap() {_65 this.map = await GoogleMap.create({_65 forceCreate: true, // Prevent issues with live reload_65 id: 'my-map',_65 element: this.mapRef.nativeElement,_65 apiKey: environment.mapsKey,_65 config: {_65 center: {_65 lat: 51.8,_65 lng: 7.6,_65 },_65 zoom: 7,_65 },_65 })_65 this.map.enableCurrentLocation(true)_65 }_65_65 async showNearby() {_65 const modal = await this.modalCtrl.create({_65 component: NearbyPage,_65 })_65 modal.present()_65 }_65_65 async addStore() {_65 const modal = await this.modalCtrl.create({_65 component: StorePage,_65 })_65 modal.present()_65 }_65}
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:
_14ion-content {_14 --background: none;_14}_14_14.container {_14 width: 100%;_14 height: 100%;_14}_14_14capacitor-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!