L'idee de Hovr est simple : vous marchez dans la rue, vous ouvrez l'app, et vous decouvrez des messages laisses par d'autres personnes a cet endroit precis. Un bon plan, une alerte, un souvenir, un animal perdu. Des mots suspendus dans l'espace, visibles uniquement quand on est a proximite.
Pas de flux algorithmique, pas de followers, pas de likes. Juste des messages ancres dans le reel, qui n'existent que si vous etes la pour les lire.
Le concept : 500 metres, pas plus
Le coeur de Hovr est un rayon de visibilite de 500 metres. Sur l'app iOS, vous ne voyez que les messages dans un cercle de 500m autour de vous. Au-dela, entre 500m et 1 500m, des mystery markers apparaissent — des points d'interrogation qui vous disent "il y a quelque chose la-bas, marchez pour le decouvrir".
| Zone | Distance | Affichage | Objectif |
|---|---|---|---|
| Visible | 0 – 500m | Marqueurs colores + contenu lisible | Zone d'interaction principale |
| Mystere | 500m – 1 500m | Marqueurs "?" sans contenu | Inciter a l'exploration |
| Invisible | > 1 500m | Rien | Limiter la charge et la pertinence |
Ce rayon de 500m n'est pas arbitraire. C'est un equilibre entre pertinence (les messages proches sont actionables), performance (moins de donnees a charger) et engagement (l'exploration physique devient le mecanisme de decouverte).
Vue d'ensemble de l'architecture
Pas de backend custom, pas de serveur Node, pas d'API REST a maintenir. Supabase fournit tout : base de donnees, authentification, row-level security et fonctions RPC. Le seul service externe est Mapbox pour les cartes.
Les categories : 9 types de messages
Chaque Hovr est tagge avec une categorie. Pas de tags libres — le choix est contraint pour garder la coherence et permettre le filtrage :
Chaque categorie a sa couleur et son icone. Sur la carte, un coup d'oeil suffit pour distinguer un bon plan (vert) d'une alerte (rouge) ou d'un souvenir (rose).
PostGIS : la magie spatiale
Le coeur technique de Hovr est PostGIS, l'extension spatiale de PostgreSQL. C'est elle qui repond a la question "quels messages sont dans un rayon de 500m autour de moi ?" en quelques millisecondes.
Le schema de la table hovrs
CREATE TABLE hovrs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES categories(id),
content TEXT NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL, -- le type magique
is_hidden BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT content_length CHECK (
char_length(content) >= 1 AND char_length(content) <= 500
)
);
-- Index spatial GIST : rend les requetes ~10x plus rapides
CREATE INDEX hovrs_location_idx ON hovrs USING GIST (location);
Le type GEOGRAPHY(POINT, 4326) est la cle. Contrairement a un simple FLOAT latitude, FLOAT longitude, il encode les coordonnees dans le systeme geodesique WGS84 (le meme que le GPS). Les calculs de distance sont alors en metres reels, pas en degres — ce qui evite les erreurs de projection.
La fonction RPC : get_hovrs_in_radius
CREATE FUNCTION get_hovrs_in_radius(
user_lat DOUBLE PRECISION,
user_lng DOUBLE PRECISION,
radius_meters INTEGER DEFAULT 500
)
RETURNS TABLE (...) AS $$
BEGIN
RETURN QUERY
SELECT h.*, p.username, c.name, c.color,
ST_Distance(
h.location::geography,
ST_SetSRID(ST_MakePoint(user_lng, user_lat), 4326)::geography
) as distance_meters
FROM hovrs h
JOIN profiles p ON h.user_id = p.id
JOIN categories c ON h.category_id = c.id
WHERE h.is_hidden = FALSE
AND ST_DWithin(
h.location,
ST_SetSRID(ST_MakePoint(user_lng, user_lat), 4326)::geography,
radius_meters
)
ORDER BY distance_meters ASC;
END;
$$ LANGUAGE plpgsql;
ST_DWithin est le predicat spatial qui fait le travail. Il utilise l'index GIST pour trouver les points dans le rayon sans scanner toute la table. Pour 10 000 Hovrs, la requete prend ~150ms — meme sur le plan gratuit de Supabase.
Les mystery markers : la deuxieme requete
Une fonction similaire get_mystery_hovrs retourne les Hovrs entre 500m et 1 500m, mais sans le contenu — juste les coordonnees et la distance. L'utilisateur voit qu'il y a quelque chose, mais pas quoi. Il faut marcher.
-- Anneau 500m-1500m : position seulement, pas de contenu
WHERE ST_DWithin(h.location, ..., 1500) -- dans le cercle exterieur
AND NOT ST_DWithin(h.location, ..., 500) -- mais pas dans le cercle interieur
L'app iOS : SwiftUI + MVVM
L'app iOS est construite en SwiftUI avec une architecture MVVM stricte. Le LocationService track la position GPS, le MapViewModel gere les Hovrs visibles, et les vues ne font que du rendu.
Ecriture d'un Hovr en PostGIS
Quand l'utilisateur cree un message, les coordonnees sont envoyees au format WKT (Well-Known Text) que PostGIS comprend nativement :
// Swift — CreateHovrRequest
struct CreateHovrRequest: Encodable {
let userId: UUID
let categoryId: Int
let content: String
let location: String // format PostGIS
init(userId: UUID, categoryId: Int, content: String,
latitude: Double, longitude: Double) {
self.userId = userId
self.categoryId = categoryId
self.content = content
self.location = "POINT(\(longitude) \(latitude))"
}
}
Rafraichissement a 50 metres
L'app ne poll pas en continu. Le CLLocationManager est configure avec un distanceFilter de 50 metres — le delegate n'est appele que quand l'utilisateur s'est deplace de 50m. A chaque mouvement significatif, les Hovrs sont recharges avec le nouveau centre :
// LocationService.swift
locationManager.distanceFilter = 50 // metres
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
self.userLocation = location.coordinate
self.onSignificantLocationChange?() // reload Hovrs
}
Pas de timer, pas de polling. L'app est reactive a la position, pas au temps.
Le web : Mapbox GL JS en vanilla
L'interface web est un viewer de carte en vanilla JS avec Mapbox GL JS. Elle affiche tous les Hovrs sans restriction de distance — c'est un outil de visualisation et de monitoring, pas l'experience principale.
Le clustering client-side
Quand plusieurs Hovrs sont au meme endroit (a moins de 5 metres), le web les regroupe en clusters. Le calcul de distance utilise la formule de Haversine — la distance a vol d'oiseau sur une sphere :
// Haversine : distance en metres entre deux coordonnees GPS
getDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // rayon de la Terre en metres
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
L'affichage en bulle de BD
Quand on clique sur un marqueur, le detail du Hovr apparait dans une bulle de bande dessinee — un element CSS avec un triangle en pseudo-element, qui pointe vers le marqueur :
#hovr-detail::after {
content: '';
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
border-left: 12px solid transparent;
border-right: 12px solid transparent;
border-top: 15px solid white;
}
L'animation d'apparition utilise un cubic-bezier avec rebond — un scale qui depasse 1 avant de se stabiliser, comme une bulle qui "pop" :
@keyframes bubblePop {
0% { opacity: 0; transform: translateX(-50%) scale(0.5) translateY(20px); }
100% { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); }
}
Securite : Row Level Security
Supabase expose directement la base de donnees aux clients. La securite ne repose pas sur un backend qui filtre — elle est dans les RLS policies de PostgreSQL :
-- Tout le monde peut lire les Hovrs non-caches
CREATE POLICY "Hovrs visible by all"
ON hovrs FOR SELECT
USING (is_hidden = FALSE);
-- On ne peut creer que ses propres Hovrs
CREATE POLICY "Create own hovrs"
ON hovrs FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Seuls les admins peuvent modifier
CREATE POLICY "Admins update any hovr"
ON hovrs FOR UPDATE
USING (auth.uid() IN (SELECT user_id FROM admin_users));
C'est un changement de paradigme par rapport a un backend classique. Au lieu de valider dans le controller, on valide dans la base. Le client peut essayer n'importe quoi — PostgreSQL refuse si la policy ne matche pas.
Moderation : le dashboard admin
Un dashboard web a /check/ permet de moderer les contenus :
- Flags — les utilisateurs peuvent signaler un Hovr. L'admin voit les signalements en attente et decide : cacher le message ou le laisser
- Ban — bannir un utilisateur cache automatiquement tous ses Hovrs et commentaires via une fonction SQL
SECURITY DEFINER - Stats — une vue SQL
admin_statsagrege les compteurs en une seule requete
-- Bannir un utilisateur = cacher tout son contenu
CREATE FUNCTION ban_user(target_user_id UUID, reason TEXT)
RETURNS VOID AS $$
BEGIN
UPDATE profiles SET is_banned = TRUE, banned_reason = reason
WHERE id = target_user_id;
UPDATE hovrs SET is_hidden = TRUE WHERE user_id = target_user_id;
UPDATE comments SET is_hidden = TRUE WHERE user_id = target_user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Structure du projet
Stack technique
- App iOS — Swift 5.9, SwiftUI, iOS 17+, MVVM
- Web — HTML/CSS/JS vanilla, Mapbox GL JS
- Backend — Supabase (PostgreSQL + PostGIS), fonctions RPC
- Auth — Supabase Auth (email + Sign in with Apple)
- Cartes — Mapbox (iOS SDK + GL JS)
- Securite — Row Level Security (RLS), policies PostgreSQL
- Moderation — dashboard admin web, flags, bans
- Cout — plan gratuit Supabase + Mapbox (50k chargements/mois gratuits)
Ce que j'ai appris
- PostGIS change la donne. Calculer des distances sur une sphere, filtrer par rayon, indexer spatialement — tout ca en SQL natif. Pas besoin de librairie externe ou de calcul cote client.
- Supabase est un vrai raccourci. Auth, base de donnees, RLS, fonctions RPC — tout est la. Pour un MVP, c'est un gain de temps enorme par rapport a un backend custom.
- Le rayon de visibilite est un mecanisme de jeu. Les mystery markers transforment une simple carte en experience d'exploration. C'est du game design applique a une app sociale.
- RLS remplace le backend. Au lieu de valider dans un controller, on valide dans la base. C'est plus sur (impossible a contourner) et plus simple (une seule source de verite).
- Le clustering est un probleme de cartographie classique. La formule de Haversine parait intimidante, mais c'est juste de la trigonometrie sur une sphere. Et 5 metres de seuil, c'est suffisant pour eviter l'empilement de marqueurs.