Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
โ† Stone Maps

The Map: Building Place Discovery with Mapbox GL

#stonemaps#devlog#feature#build

The Map: Building Place Discovery with Mapbox GL

Stone Maps has a map. It's not the primary surface โ€” the journal is โ€” but it's the part of the app that makes the concept legible to someone who's never seen it before. Points scattered across the world, each one a moment someone chose to record in a place. Some of them yours, some of them strangers'.

The map uses Mapbox GL. Here's how it's wired together and the decisions that shaped it.

Why Mapbox

Mapbox GL JS is the standard for interactive WebGL maps that need custom styling, performance at zoom, and vector tile rendering. The alternatives โ€” Leaflet (older, raster-based), Google Maps (licensing costs, less control), OpenLayers (powerful but heavier) โ€” all have tradeoffs that pushed us toward Mapbox.

The main cost is the API key and Mapbox's pricing model, which is session-based past a free tier. For early access with 50 users, the free tier is more than sufficient. For wider launch, it's a cost to plan for.

The NEXT_PUBLIC_MAPBOX_TOKEN env var is the only Mapbox-specific configuration. The token is scoped to specific domains in the Mapbox dashboard โ€” so if someone pulls the token from the client bundle (it's public by design), they can't use it on unauthorized domains.

Viewport-Based Queries

The map doesn't load all posts on startup. It queries posts for the current viewport on every pan or zoom, using the GET /api/map/posts endpoint with a bounding box:

GET /api/map/posts?north=51.5&south=51.4&east=-0.1&west=-0.2&teamId=...&campaignId=...

The PostGIS query uses ST_Within with ST_GeomFromText to build a polygon from the four bounding coordinates:

const bbox = `POLYGON((${west} ${south}, ${east} ${south}, ${east} ${north}, ${west} ${north}, ${west} ${south}))`;

sql`ST_Within(${posts.location}, ST_GeomFromText(${bbox}, 4326))`

Note: this is a flat-geometry query (ST_Within on geometry, not geography), which means it doesn't account for the curvature of the earth. For map viewport queries covering city-scale or smaller areas, this is fine โ€” the distortion is negligible. For queries spanning continents, it would clip poles and distort at high latitudes. The map doesn't support that use case, so the simplification holds.

Results are capped at 500. If a viewport contains more than 500 posts, the oldest ones won't appear. This is a performance choice we'll revisit when we have enough posts to hit it.

Filtering by Team and Campaign

The map endpoint accepts optional teamId and campaignId parameters. When viewing a team's campaign, the map shows only that campaign's posts within the viewport:

typescript
if (teamId) {
  conditions = and(conditions, sql`${posts.teamId} = ${teamId}`);
}
if (campaignId) {
  conditions = and(conditions, sql`${posts.campaignId} = ${campaignId}`);
}

This is how a field ecology team sees their semester's work geographically: filter to the campaign, pan around the study area, see everyone's posts at their actual locations.

The MCP tool get_map_posts exposes this same filtering, which means an AI assistant can ask "show me all posts from this team's autumn campaign in the area around coordinates X,Y" and get a structured response.

What the Map Shows

The map shows public posts and, if you're a team member, team-visibility posts from your teams. Private posts โ€” the majority of journal entries โ€” don't appear on the map even if they have coordinates.

This is the core privacy decision. Logging a moment in your journal with location doesn't make it public. You have to explicitly set visibility: 'public' or visibility: 'team' for it to appear on the map. The default for new posts is private.

Each pin on the map is a post. Tapping a pin shows a preview: who posted it, when, and the first few lines of text. If it's someone else's post, there's no way to reply, follow, or interact. It's a read experience. The map is a discovery surface, not a social graph.

The Read-Only Principle

You cannot post from the map. The map has no input. You can discover that someone was at a waterfall in Slovenia and wrote something beautiful about the sound of it โ€” but you can't like it, comment on it, or follow that person.

This was a deliberate constraint. Social features on a map create a different kind of app: location-based social media. We're not building that. The map is for seeing that the world is full of people noticing things. That's it.

Whether to add even minimal interaction โ€” a quiet acknowledgment, an anonymous appreciation โ€” is something we're still thinking about. But the default has to be read-only, because adding interaction is easier than removing it once users expect it.

Nearby Discovery

The GET /api/map/nearby endpoint is the radius-based complement to the bounding-box endpoint. It's used when you want "posts near me" rather than "posts in my viewport":

GET /api/map/nearby?lat=51.5&lng=-0.1&radiusMeters=1000&limit=50

This powers the "nearby" discovery feature: when you open the app at a location, you can see what people have noticed in the surrounding area. It uses ST_DWithin on the geography type (accurate spherical distance in meters), which is the right choice for radius queries.

The two endpoints โ€” bounding box for map viewport, radius for nearby โ€” serve different UX needs. Bounding box is what the map renders. Radius is what "near me" shows in a list. Both ultimately ask the same question of PostGIS, just framed differently.