1044 lines
33 KiB
Plaintext
1044 lines
33 KiB
Plaintext
---
|
|
title: Geo Queries with PostGIS in Ionic Angular
|
|
description: Using the PostGIS extension to build a cross-platform application with Ionic Angular.
|
|
author: simon_grimm
|
|
image: 2023-02-28-geoqueries-postgis/postgis-ionic-angular.jpg
|
|
thumb: 2023-02-28-geoqueries-postgis/postgis-ionic-angular.jpg
|
|
categories:
|
|
- developers
|
|
tags:
|
|
- ionic
|
|
- postgis
|
|
- geoqueries
|
|
- storage
|
|
date: '2023-03-01'
|
|
toc_depth: 3
|
|
---
|
|
|
|
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](https://supabase.com/docs/guides/database/functions) for geo-queries with PostGIS
|
|
- Display a [Capacitor Google Map](https://capacitorjs.com/docs/apis/google-maps) with a marker
|
|
- Upload files to Supabase Storage and use [image transformations](https://supabase.com/docs/guides/storage/serving/image-transformations)
|
|
|
|
Since there are quite some code snippets we need I've put together the [full source code on GitHub](https://github.com/saimon24/supabase-postgis-ionic-angular) 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](https://supabase.com/)!
|
|
|
|
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](https://postgis.net/) 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](https://supabase.com/docs/guides/database/extensions/postgis) 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:
|
|
|
|
```sql
|
|
-- enable the PostGIS extension
|
|
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](https://postgis.net/docs/using_postgis_dbmanagement.html#build-indexes) 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**:
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
-- 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:
|
|
|
|
```sql
|
|
-- create database function to find nearby stores
|
|
create or replace function nearby_stores(lat float, long float)
|
|
returns table (id public.stores.id%TYPE, name public.stores.name%TYPE, description public.stores.description%TYPE, lat float, long float, dist_meters float)
|
|
language sql
|
|
as $$
|
|
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
|
|
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 table (id public.stores.id%TYPE, name public.stores.name%TYPE, lat float, long float)
|
|
language sql
|
|
as $$
|
|
select id, name, ST_Y(location::geometry) as lat, ST_X(location::geometry) as long
|
|
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](https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-angular) to build a cross-platform application.
|
|
|
|
Additionally we use [Capacitor](https://capacitorjs.com/) to include a [native Google Maps](https://capacitorjs.com/docs/apis/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](https://github.com/supabase/supabase-js), so go ahead and run:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```ts
|
|
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](https://console.cloud.google.com/) 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:
|
|
|
|
```xml
|
|
<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**:
|
|
|
|
```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:
|
|
|
|
```xml
|
|
<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](https://ionicacademy.com/capacitor-google-maps-plugin/)!
|
|
|
|
### 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:
|
|
|
|
```ts
|
|
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**:
|
|
|
|
```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:
|
|
|
|
```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-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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```html
|
|
<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:
|
|
|
|

|
|
|
|
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**:
|
|
|
|
```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**:
|
|
|
|
```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**:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```html
|
|
<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:
|
|
|
|

|
|
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```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>
|
|
|
|
<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**:
|
|
|
|
```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.
|
|
|
|

|
|
|
|
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**:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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.
|
|
|
|

|
|
|
|
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](https://supabase.com/docs/guides/storage/serving/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**:
|
|
|
|
```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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```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>
|
|
|
|
<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:
|
|
|
|

|
|
|
|
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](https://supabase.com/) database (and [auth](https://supabase.com/blog/authentication-in-ionic-angular)
|
|
), and storage, and so much more..)
|
|
|
|
You can [find the full code of this tutorial on GitHub](https://github.com/saimon24/supabase-postgis-ionic-angular) 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](https://galaxies.dev) where I help modern web and mobile developers build epic apps 🚀
|
|
|
|
Until next time and happy coding with Supabase!
|
|
|
|
## Related resources
|
|
|
|
- [Authentication in Ionic Angular with Supabase](https://supabase.com/blog/authentication-in-ionic-angular)
|
|
- [Building a Realtime Trello Board with Supabase and Angular](https://supabase.com/blog/building-a-realtime-trello-board-with-supabase-and-angular)
|
|
- [Build a User Management App with Ionic Angular](https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-angular)
|