Projet

Hovr - Words Suspended in Space

Leave a location-based message. Discover it while walking. Supabase, PostGIS, SwiftUI, Mapbox - the anatomy of an app that anchors words in space.

This project was built with AI in November 2025. The architecture, the choice of Supabase + PostGIS, the design of the visibility zones - these are human decisions. AI generates the code, I decide where it goes and why.

The idea behind Hovr is simple: you walk down the street, open the app, and discover messages left by other people at that exact spot. A good tip, an alert, a memory, a lost pet. Words suspended in space, visible only when you're nearby.

No algorithmic feed, no followers, no likes. Just messages anchored in the real world, that only exist if you're there to read them.

Hovr - words in suspension
Hovr - a speech bubble on a map.

The concept: 500 meters, no more

The heart of Hovr is a 500-meter visibility radius. On the iOS app, you only see messages within a 500m circle around you. Beyond that, between 500m and 1,500m, mystery markers appear - question marks that tell you "there's something over there, walk to discover it".

ZoneDistanceDisplayPurpose
Visible 0 - 500m Colored markers + readable content Main interaction zone
Mystery 500m - 1,500m "?" markers with no content Encourage exploration
Invisible > 1,500m Nothing Limit load and relevance

This 500m radius isn't arbitrary. It's a balance between relevance (nearby messages are actionable), performance (less data to load) and engagement (physical exploration becomes the discovery mechanism).

Architecture overview

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

No custom backend, no Node server, no REST API to maintain. Supabase provides everything: database, authentication, row-level security and RPC functions. The only external service is Mapbox for the maps.

Categories: 9 message types

Every Hovr is tagged with a category. No free-form tags - the choice is constrained to keep consistency and enable filtering:

🏷️ Good tip
⚠️ Alert
📅 Event
❤️ Memory
👥 Meetup
ℹ️ Local info
🐾 Lost pet
🚧 Damaged road
💩 Dog poop

Each category has its own color and icon. On the map, a single glance is enough to tell a good tip (green) from an alert (red) or a memory (pink).

PostGIS: the spatial magic

The technical core of Hovr is PostGIS, the spatial extension for PostgreSQL. It's what answers the question "which messages are within a 500m radius of me?" in a few milliseconds.

The hovrs table schema

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) 

The GEOGRAPHY(POINT, 4326) type is the key. Unlike a simple FLOAT latitude, FLOAT longitude, it encodes coordinates in the WGS84 geodetic system (the same one GPS uses). Distance calculations are then in real meters, not degrees - which avoids projection errors.

The RPC function: 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 is the spatial predicate that does the work. It uses the GIST index to find points within the radius without scanning the whole table. For 10,000 Hovrs, the query takes ~150ms - even on Supabase's free plan.

Mystery markers: the second query

A similar function get_mystery_hovrs returns Hovrs between 500m and 1,500m, but without the content - just the coordinates and distance. The user sees that something is there, but not what. They have to walk.

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

The iOS app: SwiftUI + MVVM

The iOS app is built in SwiftUI with a strict MVVM architecture. The LocationService tracks GPS position, the MapViewModel manages visible Hovrs, and the views only handle rendering.

Writing a Hovr in PostGIS

When the user creates a message, the coordinates are sent in the WKT (Well-Known Text) format that PostGIS understands natively:

// 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))"
    }
}

Refresh at 50 meters

The app doesn't poll continuously. The CLLocationManager is configured with a distanceFilter of 50 meters - the delegate is only called once the user has moved 50m. On each significant movement, Hovrs are reloaded with the new center:

// 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
}

No timer, no polling. The app is reactive to position, not to time.

The web: Mapbox GL JS in vanilla JS

The web interface is a map viewer in vanilla JS with Mapbox GL JS. It displays all Hovrs without any distance restriction - it's a visualization and monitoring tool, not the main experience.

Client-side clustering

When several Hovrs are at the same spot (less than 5 meters apart), the web app groups them into clusters. The distance calculation uses the Haversine formula - the great-circle distance on a 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));
}

The comic-book bubble display

When you click on a marker, the Hovr's detail appears in a comic-book speech bubble - a CSS element with a triangle as a pseudo-element, pointing to the marker:

#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;
}

The appearance animation uses a cubic-bezier with a bounce - a scale that overshoots 1 before settling, like a bubble that "pops":

@keyframes bubblePop {
    0%   { opacity: 0; transform: translateX(-50%) scale(0.5) translateY(20px); }
    100% { opacity: 1; transform: translateX(-50%) scale(1) translateY(0); }
}

Security: Row Level Security

Supabase exposes the database directly to clients. Security doesn't rely on a backend that filters - it lives in PostgreSQL's RLS policies :

-- 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));

This is a paradigm shift compared to a classic backend. Instead of validating in the controller, you validate in the database. The client can try anything - PostgreSQL refuses if the policy doesn't match.

Moderation: the admin dashboard

A web dashboard /check/ allows moderating content:

  • Flags - users can report a Hovr. The admin sees pending reports and decides: hide the message or leave it
  • Ban - banning a user automatically hides all their Hovrs and comments via a SQL function SECURITY DEFINER
  • Stats - a SQL view admin_stats aggregates the counts in a single query
-- 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;

Project structure

hovr.cloud/ | |-- index.html Web app (Mapbox map) |-- js/ | |-- app.js Main orchestrator | |-- map.js Mapbox + Haversine clustering | |-- supabase.js Supabase client + RPC | |-- filters.js Category filtering | |-- check/ Admin dashboard | |-- dashboard.html Stats | |-- users.html Ban / unban | |-- hovrs.html Content moderation | |-- flags.html Reports | |-- ios/Hovr/ iOS SwiftUI app |-- 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

Tech stack

  • iOS app - Swift 5.9, SwiftUI, iOS 17+, MVVM
  • Web - vanilla HTML/CSS/JS, Mapbox GL JS
  • Backend - Supabase (PostgreSQL + PostGIS), RPC functions
  • Auth - Supabase Auth (email + Sign in with Apple)
  • Maps - Mapbox (iOS SDK + GL JS)
  • Security - Row Level Security (RLS), PostgreSQL policies
  • Moderation - web admin dashboard, flags, bans
  • Cost - Supabase free plan + Mapbox (50k free loads/month)

What I learned

  • PostGIS changes the game. Calculating distances on a sphere, filtering by radius, indexing spatially - all in native SQL. No need for an external library or client-side computation.
  • Supabase is a genuine shortcut. Auth, database, RLS, RPC functions - it's all there. For an MVP, it's a massive time saver compared to a custom backend.
  • The visibility radius is a game mechanic. Mystery markers turn a simple map into an exploration experience. It's game design applied to a social app.
  • RLS replaces the backend. Instead of validating in a controller, you validate in the database. It's safer (impossible to bypass) and simpler (a single source of truth).
  • Clustering is a classic mapping problem. The Haversine formula sounds intimidating, but it's just trigonometry on a sphere. And a 5-meter threshold is enough to prevent marker stacking.