Overview
Sovran integrates with BTCMap.org to display over 30,000 Bitcoin-accepting merchants worldwide. The map uses high-performance clustering powered by Mapbox Supercluster to handle large datasets smoothly.
BTCMap data is sourced from OpenStreetMap and verified by the Bitcoin community. All merchant data is cached locally for 1 hour.
Features
Interactive Map
Clustering Intelligent marker clustering for smooth performance
Categories Filter by food, retail, ATMs, accommodation, or services
Privacy Location is offset to protect your privacy
Details View payment methods, hours, and contact info
Category Filtering
Filter merchants by type:
Category Icons Included Food & Drink Cafes, restaurants, bakeries Retail & Shopping Stores, groceries, computers, jewelry ATMs & Exchange Bitcoin ATMs, currency exchange Accommodation Hotels, spas Services Medical, pharmacy, salons, repair shops, gyms
Implementation Details
Data Fetching
Merchant data is fetched from the BTCMap API and cached locally:
// From stores/btcMapStore.ts
const BTCMAP_API_URL =
'https://api.btcmap.org/v4/places?fields=id,lat,lon,icon,comments,boosted_until,deleted_at,updated_at&include_deleted=false' ;
interface BTCMapPlace {
id : number ;
lat : number ;
lon : number ;
icon : string ; // Material icon name for category
updated_at : string ;
}
const PLACES_CACHE_TTL = 60 * 60 * 1000 ; // 1 hour
Clustering Algorithm
Sovran uses Mapbox’s Supercluster for O(log n) performance:
// From utils/mapClustering.ts
import Supercluster from 'supercluster' ;
export class ClusterManager {
private cluster : Supercluster ;
constructor ( options ?: Supercluster . Options ) {
this . cluster = new Supercluster ({
radius: 60 , // Cluster radius in pixels
maxZoom: 16 , // Max zoom to cluster points
minPoints: 2 , // Minimum points to form a cluster
... options ,
});
}
load ( points : GeoPoint []) : void {
const features = points . map (( p ) => ({
type: 'Feature' ,
properties: { pointId: p . id , icon: p . icon },
geometry: {
type: 'Point' ,
coordinates: [ p . lon , p . lat ]
}
}));
this . cluster . load ( features );
}
getClusters ( bbox : [ number , number , number , number ], zoom : number ) : MapMarker [] {
const clusters = this . cluster . getClusters ( bbox , Math . floor ( zoom ));
return clusters . map ( this . convertToMarker );
}
}
Cluster Caching
Cluster indexes are cached to avoid rebuilding on navigation:
// From utils/btcMapClusterCache.ts
type CacheEntry = {
manager : ClusterManager ;
createdAt : number ;
pointsCount : number ;
};
const CACHE = new Map < string , CacheEntry >();
const MAX_ENTRIES = 3 ;
export function getOrBuildBTCMapClusterManager (
cacheKey : string ,
points : GeoPoint [],
options ?: ClusterBuildOptions
) : ClusterManager {
const existing = CACHE . get ( cacheKey );
if ( existing && existing . pointsCount === points . length ) {
return existing . manager ;
}
const manager = new ClusterManager ( options );
manager . load ( points );
CACHE . set ( cacheKey , { manager , createdAt: Date . now (), pointsCount: points . length });
return manager ;
}
The map uses several techniques for smooth performance:
Deferred Rendering
Map rendering is delayed 50ms to allow modal animation to start
InteractionManager
Heavy operations run after interactions complete using InteractionManager.runAfterInteractions()
Debounced Updates
Marker updates are debounced 250ms during panning/zooming
Viewport Culling
Only markers in the visible viewport (plus padding) are rendered
// From app/(map-flow)/index.tsx:586-625
const handleCameraChange = useCallback (
( event : { coordinates : { latitude ?: number ; longitude ?: number }; zoom : number }) => {
const newLat = event . coordinates . latitude ?? prev . lat ;
const newLon = event . coordinates . longitude ?? prev . lon ;
const newZoom = event . zoom ;
// Skip tiny movements within current zoom level
const zoomFloor = Math . floor ( newZoom );
const span = 360 / Math . pow ( 2 , Math . max ( newZoom , 0 ));
const latThreshold = span * 0.12 ;
const lonThreshold = span * ASPECT_RATIO * 0.12 ;
const shouldSkip =
last ?. zoomFloor === zoomFloor &&
Math . abs ( newLat - last . lat ) < latThreshold &&
Math . abs ( newLon - last . lon ) < lonThreshold ;
if ( shouldSkip ) return ;
// Debounce marker updates
clearTimeout ( markerUpdateTimer );
markerUpdateTimer = setTimeout (() => {
InteractionManager . runAfterInteractions (() => {
updateMarkersForCamera ( newLat , newLon , newZoom );
});
}, 250 );
},
[ updateMarkersForCamera ]
);
Bounding Box Calculation
Convert camera position to bounding box for cluster queries:
// From utils/mapClustering.ts:206-232
export function cameraToBbox (
lat : number ,
lon : number ,
zoom : number ,
aspectRatio : number = 1 ,
padding : number = 1.0 // 100% padding to show pins outside viewport
) : [ number , number , number , number ] {
// At zoom 0, you see ~360 degrees. Each zoom level halves this.
const baseSpan = 360 / Math . pow ( 2 , zoom );
const latSpan = baseSpan * ( 1 + padding );
const lonSpan = baseSpan * aspectRatio * ( 1 + padding );
// Clamp to valid ranges
const west = Math . max ( - 180 , lon - lonSpan / 2 );
const east = Math . min ( 180 , lon + lonSpan / 2 );
const south = Math . max ( - 85 , lat - latSpan / 2 ); // Web Mercator limit
const north = Math . min ( 85 , lat + latSpan / 2 );
return [ west , south , east , north ];
}
Merchant Details
Data Model
Detailed merchant information includes:
// From stores/btcMapStore.ts:16-55
export interface BTCMapPlaceDetails {
id : number ;
lat : number ;
lon : number ;
name ?: string ;
address ?: string ;
description ?: string ;
// Payment methods
'osm:payment:onchain' ?: string ; // 'yes' | 'no'
'osm:payment:lightning' ?: string ; // 'yes' | 'no'
'osm:payment:lightning_contactless' ?: string ; // 'yes' | 'no'
// Contact info (prefer osm:contact: fields)
'osm:contact:phone' ?: string ;
'osm:contact:website' ?: string ;
'osm:contact:email' ?: string ;
'osm:contact:instagram' ?: string ;
'osm:contact:twitter' ?: string ;
// Legacy fields
phone ?: string ;
website ?: string ;
email ?: string ;
instagram ?: string ;
twitter ?: string ;
// Other
opening_hours ?: string ;
verified_at ?: string ;
updated_at : string ;
}
Detail Screen
The detail screen displays comprehensive merchant information:
// From app/(map-flow)/detail.tsx:62-380
export default function MerchantDetailScreen () {
const { placeId } = useLocalSearchParams <{ placeId : string }>();
const { fetchPlaceDetails , getCachedPlaceDetails } = useBTCMapStore ();
const [ place , setPlace ] = useState < BTCMapPlaceDetails | null >( null );
// Check cache first, then fetch from API
const cached = getCachedPlaceDetails ( id );
if ( cached ) {
setPlace ( cached );
} else {
const details = await fetchPlaceDetails ( id );
setPlace ( details );
}
}
The UI shows:
Payment methods (on-chain, Lightning, contactless)
Contact information (phone, website, email, socials)
Opening hours
Description
Verification status and date
Privacy Features
Location Offsetting
User location is offset to prevent exact position tracking:
// From utils/locationPrivacy.ts (referenced in map code)
export function applySafetyOffset (
latitude : number ,
longitude : number
) : { latitude : number ; longitude : number } {
// Apply random offset so camera doesn't center on exact position
// Offset is consistent within session but varies between sessions
const offsetLat = ( Math . random () - 0.5 ) * 0.01 ; // ~1km range
const offsetLon = ( Math . random () - 0.5 ) * 0.01 ;
return {
latitude: latitude + offsetLat ,
longitude: longitude + offsetLon
};
}
This is applied when:
Centering map on user location
Using “My Location” button
Initial map load with permissions
Map Controls
Map controls are rendered as glass-effect buttons on iOS:
// From app/(map-flow)/index.tsx:189-282
const FloatingActionButtons = memo (({ onMyLocation , onZoomIn , onZoomOut }) => {
return (
< VStack style = {styles. floatingButtons } spacing = { 8 } >
< Host style = {{ height : 48 , width : 48 }} >
< SwiftUIButton
modifiers = { [
buttonStyle ( 'glass' ),
frame ({ height: 48 , width: 48 }),
glassEffect ({ shape: 'circle' , glass: { variant: 'regular' } })
]}
onPress={onMyLocation}>
<SwiftUIImage systemName="location.fill" size={20} />
</SwiftUIButton>
</Host>
{ /* Zoom buttons... */ }
</VStack>
);
});
Stats Card
Contextual stats with category filter:
// From app/(map-flow)/index.tsx:121-187
const StatsCard = memo (({ visibleCount , totalCount , category , onCategoryChange }) => {
return (
< Host style = {{ width : SCREEN_WIDTH - 32 }} >
< ContextMenu >
< ContextMenu . Items >
{ categories . map (( cat ) => (
< SwiftUIButton
label = {cat. label }
onPress = {() => onCategoryChange ( cat )}
/>
))}
</ ContextMenu . Items >
< ContextMenu . Trigger >
< SwiftUIButton modifiers = { [ glassEffect ({ shape: 'capsule' })]}>
<SwiftUIImage systemName="bitcoinsign.circle.fill" />
<SwiftUIText>{visibleCount.toLocaleString()} visible</SwiftUIText>
<SwiftUIText>{totalCount.toLocaleString()} total • {category}</SwiftUIText>
</SwiftUIButton>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
);
});
Best Practices
Loading States : Show skeleton/loading while fetching data
Error Handling : Gracefully handle network failures with cached data
Cluster Expansion : Tap cluster to zoom in and reveal individual merchants
Deep Links : Support direct navigation to merchant details
Location Offsetting : Never center map on exact user location
Permission Requests : Request location permission gracefully
Local Storage : Cache data locally to minimize API requests
Code Reference
Source Files
app/(map-flow)/index.tsx:1-778 - Main map screen with clustering
app/(map-flow)/detail.tsx:1-414 - Merchant detail view
stores/btcMapStore.ts:1-243 - Data fetching and caching
utils/mapClustering.ts:1-233 - Supercluster wrapper
utils/btcMapClusterCache.ts:1-67 - Cluster manager caching
Key Functions
fetchPlaces() - Fetch all merchants from BTCMap API
fetchPlaceDetails(id) - Fetch detailed merchant information
getClusters(bbox, zoom) - Get clustered markers for viewport
getClusterExpansionZoom(clusterId) - Calculate zoom level to expand cluster
cameraToBbox(lat, lon, zoom) - Convert camera to bounding box