Blog post

Geo Queries with PostGIS in Ionic Angular

2023-03-01

32 minute read

Geo Queries with PostGIS in Ionic Angular

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!

Supabase Postgis app Ionic

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!

Supabase new project

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:

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

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

Supabase 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:

-- create our table
create table if not exists public.stores (
	id int generated by default as identity primary key,
	name text not null,
  description text,
	location geography(POINT) not null
);

-- add the spatial index
create index stores_geo_index
  on public.stores
  using GIST (location);

-- create a storage bucket and allow file upload/download
insert into storage.buckets (id, name)
values ('stores', 'stores');

CREATE POLICY "Select images" ON storage.objects FOR SELECT TO public USING (bucket_id = 'stores');
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:

-- add some dummy data
insert into public.stores
(name, description, location)
values
  ('The Galaxies.dev Shop', 'Galaxies.dev - your favourite place to learn', st_point(7.6005702, 51.8807174)),
  ('The Local Dev', 'Local people, always best', st_point(7.614454, 51.876565)),
  ('City Store', 'Get the supplies a dev needs', st_point(7.642581, 51.945606)),
  ('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:

-- create database function to find nearby stores
create or replace function nearby_stores(lat float, long float)
returns setof record
language sql
as $$
  select id, name, description, st_astext(location) as location, st_distance(location, st_point(long, lat)::geography) as dist_meters
  from public.stores
  order by location <-> st_point(long, lat)::geography;
$$;


-- create database function to find stores in a specific box
create or replace function stores_in_view(min_lat float, min_long float, max_lat float, max_long float)
returns setof record
language sql
as $$
	select id, name, ST_Y(location::geometry) as lat, ST_X(location::geometry) as long, st_astext(location) as location
	from public.stores
	where location && ST_SetSRID(ST_MakeBox2D(ST_Point(min_long, min_lat), ST_Point(max_long, max_lat)),4326)
$$;

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:

ionic start supaMap blank --type=angular
cd ./supaMap

ionic g page store
ionic g page nearby
ionic g service services/stores

ionic build
ionic cap add ios
ionic cap add android


# Add Maps and Geolocation plugins
npm install @capacitor/google-maps
npm install @capacitor/geolocation

# Install Supabase
npm install @supabase/supabase-js

# Ionic 7 wasn't released so I installed the next version
# not required if you are already on Ionic 7
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:

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

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:

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

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

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

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<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:

import { Injectable } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { SupabaseClient, User, createClient } from '@supabase/supabase-js';
import { environment } from 'src/environments/environment';

export interface StoreEntry {
  lat?: number;
  long?: number;
  name: string;
  description: string;
  image?: File;
}
export interface StoreResult {
  id: number;
  lat: number;
  long: number;
  name: string;
  description: string;
  image?: SafeUrl;
  dist_meters?: number;
}
@Injectable({
  providedIn: 'root',
})
export class StoresService {
  private supabase: SupabaseClient;

  constructor(private sanitizer: DomSanitizer) {
    this.supabase = createClient(
      environment.supabaseUrl,
      environment.supabaseKey
    );
  }

  // Get all places with calculated distance
  async getNearbyStores(lat: number, long: number) {
    const { data, error } = await this.supabase.rpc('nearby_stores', {
      lat,
      long,
    });
    return data;
  }
}

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:

import { Component } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { NearbyPage } from '../nearby/nearby.page';

export interface StoreMarker {
  markerId: string;
  storeId: number;
}

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  constructor(
    private modalCtrl: ModalController
  ) {}

  async showNearby() {
    const modal = await this.modalCtrl.create({
      component: NearbyPage,
    });
    modal.present();
  }
}

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

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

    <ion-title> Supa Stores </ion-title>
  </ion-toolbar>
</ion-header>

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

import { Component, OnInit } from '@angular/core';
import { Geolocation } from '@capacitor/geolocation';
import { StoresService, StoreResult } from '../services/stores.service';
import { LoadingController, ModalController } from '@ionic/angular';

@Component({
  selector: 'app-nearby',
  templateUrl: './nearby.page.html',
  styleUrls: ['./nearby.page.scss'],
})
export class NearbyPage implements OnInit {
  stores: StoreResult[] = [];

  constructor(
    private storesService: StoresService,
    public modalCtrl: ModalController,
    private loadingCtrl: LoadingController
  ) {}

  async ngOnInit() {
    // Show loading while getting data from Supabase
    const loading = await this.loadingCtrl.create({
      message: 'Loading nearby places...',
    });
    loading.present();

    const coordinates = await Geolocation.getCurrentPosition();

    if (coordinates) {
      // Get nearby places sorted by distance using PostGIS
      this.stores = await this.storesService.getNearbyStores(
        coordinates.coords.latitude,
        coordinates.coords.longitude
      );
      loading.dismiss();
    }
  }
}

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:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-button (click)="modalCtrl.dismiss()">
        <ion-icon slot="icon-only" name="close"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>Nearby Places</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-list>
    <ion-item *ngFor="let store of stores">
      <ion-label>
        {{ store.name }}
        <p>{{store.description }}</p>
      </ion-label>
      <ion-note slot="end"
        >{{store.dist_meters!/1000 | number:'1.0-2' }} km</ion-note
      >
    </ion-item>
  </ion-list>
</ion-content>

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

Ionic nearby list

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:

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

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:

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

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

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

    <ion-title> Supa Stores </ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="addStore()">
        <ion-icon name="add" slot="start"></ion-icon> Store</ion-button
      >
    </ion-buttons>
  </ion-toolbar>
</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:

import { Component, OnInit } from '@angular/core';
import { ModalController } from '@ionic/angular';
import { StoreEntry, StoresService } from '../services/stores.service';

@Component({
  selector: 'app-store',
  templateUrl: './store.page.html',
  styleUrls: ['./store.page.scss'],
})
export class StorePage implements OnInit {
  store: StoreEntry = {
    name: '',
    description: '',
    image: undefined,
    lat: undefined,
    long: undefined,
  };

  constructor(
    public modalCtrl: ModalController,
    private storesService: StoresService
  ) {}

  ngOnInit() {}

  imageSelected(ev: any) {
    this.store.image = ev.detail.event.target.files[0];
  }

  async addStore() {
    this.storesService.addStore(this.store);
    this.modalCtrl.dismiss();
  }
}

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:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-button (click)="modalCtrl.dismiss()">
        <ion-icon slot="icon-only" name="close"></ion-icon>
      </ion-button>
    </ion-buttons>
    <ion-title>Add Store</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
  <ion-input
    label="Store name"
    label-placement="stacked"
    placeholder="Joeys"
    [(ngModel)]="store.name"
  />
  <ion-textarea
    rows="3"
    label="Store description"
    label-placement="stacked"
    placeholder="Some about text"
    [(ngModel)]="store.description"
  />
  <ion-input
    type="number"
    label="Latitude"
    label-placement="stacked"
    [(ngModel)]="store.lat"
  />
  <ion-input
    type="number"
    label="Longitude"
    label-placement="stacked"
    [(ngModel)]="store.long"
  />
  <ion-input
    label="Select store image"
    (ionChange)="imageSelected($event)"
    type="file"
    accept="image/*"
  ></ion-input>

  <ion-button
    expand="full"
    (click)="addStore()"
    [disabled]="!store.lat || !store.long || store.name === ''"
    >Add Store</ion-button
  >
</ion-content>

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

Ionic add store

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:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { HomePage } from './home.page';

import { HomePageRoutingModule } from './home-routing.module';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
  imports: [CommonModule, FormsModule, IonicModule, HomePageRoutingModule],
  declarations: [HomePage],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
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:

import { Component, ElementRef, ViewChild } from '@angular/core';
import { GoogleMap } from '@capacitor/google-maps';
import { LatLngBounds } from '@capacitor/google-maps/dist/typings/definitions';
import { ModalController } from '@ionic/angular';
import { BehaviorSubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { NearbyPage } from '../nearby/nearby.page';
import { StoreResult, StoresService } from '../services/stores.service';
import { StorePage } from '../store/store.page';

export interface StoreMarker {
  markerId: string;
  storeId: number;
}

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  @ViewChild('map') mapRef!: ElementRef<HTMLElement>;
  map!: GoogleMap;
  mapBounds = new BehaviorSubject<LatLngBounds | null>(null);
  activeMarkers: StoreMarker[] = [];
  selectedMarker: StoreMarker | null = null;
  selectedStore: StoreResult | null = null;

  constructor(
    private storesService: StoresService,
    private modalCtrl: ModalController
  ) {}

  ionViewDidEnter() {
    this.createMap();
  }
  
  async createMap() {
    this.map = await GoogleMap.create({
      forceCreate: true, // Prevent issues with live reload
      id: 'my-map',
      element: this.mapRef.nativeElement,
      apiKey: environment.mapsKey,
      config: {
        center: {
          lat: 51.8,
          lng: 7.6,
        },
        zoom: 7,
      },
    });
    this.map.enableCurrentLocation(true);
  }

  async showNearby() {
    const modal = await this.modalCtrl.create({
      component: NearbyPage,
    });
    modal.present();
  }

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

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:

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

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

<ion-content>
  <div class="container">
    <capacitor-google-map #map></capacitor-google-map>
  </div>
</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:

ion-content {
  --background: none;
}

.container {
  width: 100%;
  height: 100%;
}

capacitor-google-map {
  display: inline-block;
  width: 100%;
  height: 100%;
}

Now the map should fill the whole screen.

Capacitor antive map

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:

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

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:

  async createMap() {
    this.map = await GoogleMap.create({
      forceCreate: true, // Prevent issues with live reload
      id: 'my-map',
      element: this.mapRef.nativeElement,
      apiKey: environment.mapsKey,
      config: {
        center: {
          lat: 51.8,
          lng: 7.6,
        },
        zoom: 7,
      },
    });
    this.map.enableCurrentLocation(true);

    // Listen to biew changes and emit to our Behavior Subject
    this.map.setOnBoundsChangedListener((ev) => {
      this.mapBounds.next(ev.bounds);
    });

    // React to changes of our subject with a 300ms delay so we don't trigger a reload all the time
    this.mapBounds.pipe(debounce((i) => interval(300))).subscribe((res) => {
      this.updateStoresInView();
    });

    // Get the current user coordinates
    this.loadUserLocation();
  }

  async updateStoresInView() {
    const bounds = await this.map.getMapBounds();

    // Get stores in our bounds using PostGIS
    const stores = await this.storesService.getStoresInView(
      bounds.southwest.lat,
      bounds.southwest.lng,
      bounds.northeast.lat,
      bounds.northeast.lng
    );

    // Update markers for elements
    this.addMarkers(stores);
  }

  async loadUserLocation() {
    // TODO
  }

  async addMarkers(stores: StoreResult[]) {
    // TODO
  }

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:

  async loadUserLocation() {
    // Get location with Capacitor Geolocation plugin
    const coordinates = await Geolocation.getCurrentPosition();

    if (coordinates) {
      // Focus the map on user and zoom in
      this.map.setCamera({
        coordinate: {
          lat: coordinates.coords.latitude,
          lng: coordinates.coords.longitude,
        },
        zoom: 14,
      });
    }
  }

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:

  async addMarkers(stores: StoreResult[]) {
    // Skip if there are no results
    if (stores.length === 0) {
      return;
    }

    // Find marker that are outside of the view
    const toRemove = this.activeMarkers.filter((marker) => {
      const exists = stores.find((item) => item.id === marker.storeId);
      return !exists;
    });

    // Remove markers
    if (toRemove.length) {
      await this.map.removeMarkers(toRemove.map((marker) => marker.markerId));
    }

    // Create new marker array
    const markers: Marker[] = stores.map((store) => {
      return {
        coordinate: {
          lat: store.lat,
          lng: store.long,
        },
        title: store.name,
      };
    });

    // Add markers, store IDs
    const newMarkerIds = await this.map.addMarkers(markers);

    // Crate active markers by combining information
    this.activeMarkers = stores.map((store, index) => {
      return {
        markerId: newMarkerIds[index],
        storeId: store.id,
      };
    });

    this.addMarkerClicks();
  }

  addMarkerClicks() {
    // TODO
  }

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.

Ionic map with marker

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:

  // Load data from Supabase database
  async loadStoreInformation(id: number) {
    const { data } = await this.supabase
      .from('stores')
      .select('*')
      .match({ id })
      .single();
    return data;
  }

  async getStoreImage(id: number) {
    // Get image for a store and transform it automatically!
    return this.supabase.storage
      .from('stores')
      .getPublicUrl(`images/${id}.png`, {
        transform: {
          width: 300,
          resize: 'contain',
        },
      }).data.publicUrl;
  }

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:

  addMarkerClicks() {
    // Handle marker clicks
    this.map.setOnMarkerClickListener(async (marker) => {
      // Find our local object based on the marker ID
      const info = this.activeMarkers.filter(
        (item) => item.markerId === marker.markerId.toString()
      );
      if (info.length) {
        this.selectedMarker = info[0];

        // Load the store information from Supabase Database
        this.selectedStore = await this.storesService.loadStoreInformation(
          info[0].storeId
        );

        // Get the iamge from Supabase Storage
        const img = await this.storesService.getStoreImage(
          this.selectedStore!.id
        );
        if (img) {
          this.selectedStore!.image = img;
        }
      }
    });
  }

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:

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

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

<ion-content>
  <div class="container">
    <capacitor-google-map #map></capacitor-google-map>
  </div>

  <ion-modal
    [isOpen]="selectedMarker !== null"
    [breakpoints]="[0, 0.4, 1]"
    [initialBreakpoint]="0.4"
    (didDismiss)="selectedMarker = null;"
  >
    <ng-template>
      <ion-content class="ion-padding">
        <ion-label class="ion-texst-wrap">
          <h1>{{selectedStore?.name}}</h1>
          <ion-note>{{selectedStore?.description}}</ion-note>
        </ion-label>
        <div class="ion-text-center ion-margin-top">
          <img [src]="selectedStore?.image" *ngIf="selectedStore?.image" />
        </div>
      </ion-content>
    </ng-template>
  </ion-modal>
</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:

Ionic marker modal with image

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

Last post

Supabase Beta February 2023

8 March 2023

Next post

Type Constraints in 65 lines of SQL

17 February 2023

Build in a weekend, scale to millions