Hovr — des mots en suspension

Deposer un message geolocalise. Le decouvrir en marchant. Supabase, PostGIS, SwiftUI, Mapbox — anatomie d'une app qui ancre des mots dans l'espace.

Ce projet a ete construit avec l'IA en novembre 2025. L'architecture, le choix de Supabase + PostGIS, le design des zones de visibilite — ce sont des decisions humaines. L'IA genere le code, moi je decide ou il va et pourquoi.

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.

Hovr — des mots en suspension
Hovr — une bulle de parole sur une carte.

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".

ZoneDistanceAffichageObjectif
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

App iOS (SwiftUI) Web (Mapbox GL JS) | | | GPS + rayon 500m | viewport bounds v v +------------------------------------------+ | Supabase (PostgreSQL + PostGIS) | | | | get_hovrs_in_radius(lat, lng, 500) | | get_mystery_hovrs(lat, lng, 500, 1500) | | ST_DWithin() + index GIST | +------------------------------------------+ | | | Auth (email + | RLS policies | Sign in Apple) | (row-level security) v v +---------------------+ +------------------+ | Supabase Auth | | Admin Dashboard | | Session + JWT | | /check/ | +---------------------+ | moderation + bans | +------------------+

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 :

🏷️ Bon plan
⚠️ Alerte
📅 Evenement
❤️ Souvenir
👥 Rencontre
ℹ️ Info locale
🐾 Animal perdu
🚧 Voirie degradee
💩 Crotte de chien

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_stats agrege 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

hovr.cloud/ | |-- index.html Web app (carte Mapbox) |-- js/ | |-- app.js Orchestrateur principal | |-- map.js Mapbox + clustering Haversine | |-- supabase.js Client Supabase + RPC | |-- filters.js Filtrage par categorie | |-- check/ Dashboard admin | |-- dashboard.html Stats | |-- users.html Ban / unban | |-- hovrs.html Moderation contenu | |-- flags.html Signalements | |-- ios/Hovr/ App iOS SwiftUI |-- Config/ Constants (500m), Theme, Environment |-- Models/ Hovr, Category, MysteryHovr |-- Services/ Supabase, Auth, Location, Hovr |-- ViewModels/ MapVM, CreateHovrVM, AuthVM |-- Views/ |-- Map/ RadiusOverlay, MysteryAnnotation |-- Auth/ Login, Register, Sign in with Apple |-- Create/ CreateHovrView

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.
Retour aux articles