When rendering points over a map using React Native Skia as an overlay, we faced a fundamental issue: synchronization latency.
┌─────────────────────────────────────────────────────────────┐
│ SKIA OVERLAY ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Google Maps (Native Thread) │
│ │ │
│ ▼ │
│ Camera Move Event │
│ │ │
│ ▼ │
│ JS Bridge (async) ──────► Latency ~16-32ms │
│ │ │
│ ▼ │
│ Reanimated SharedValue │
│ │ │
│ ▼ │
│ Skia Canvas Redraw ─────► "Lagging" points │
│ │
└─────────────────────────────────────────────────────────────┘
Issues encountered:
- Visual Desynchronization: Points moved with the map for 1-2 frames before being repositioned.
- FPS Drops: Projecting 10k+ points on the JS thread (even with worklets) caused FPS drops.
- Complexity: We tried various solutions (native events, Choreographer, C++ projection) without total success.
TileOverlay is a native Google Maps API that allows rendering custom tiles as part of the map itself. This means perfect synchronization.
┌─────────────────────────────────────────────────────────────┐
│ TILEOVERLAY ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Google Maps (Native Thread) │
│ │ │
│ ├──► Renders Base Tiles │
│ │ │
│ └──► Renders Custom Tiles (points) ◄── SAME CYCLE │
│ │ │
│ ▼ │
│ SpatialTileProvider │
│ │ │
│ ▼ │
│ C++ getPointsForTile() │
│ │ │
│ ▼ │
│ Bitmap 512x512 PNG │
│ │
│ ✅ Perfect Sync - Same render pipeline │
│ │
└─────────────────────────────────────────────────────────────┘
Advantages:
| Aspect | Skia Overlay | TileOverlay |
|---|---|---|
| Synchronization | ❌ 1-2 frames latency | ✅ Perfect |
| Performance | ✅ Native | |
| Scalability | ✅ 200k+ points | |
| Cache | ❌ Redraws every frame | ✅ Cached tiles |
| Complexity | High (bridge, worklets) | Medium |
expo-spatial-layer/
├── index.tsx # Public export
├── src/
│ ├── ExpoSpatialLayer.types.ts # TypeScript types
│ ├── ExpoSpatialLayerModule.ts # JS → Native Interface
│ └── ExpoSpatialLayerView.tsx # React Component
├── cpp/
│ ├── SpatialLayer.cpp # Core C++ engine
│ └── SpatialLayer.h
├── android/
│ └── src/main/
│ ├── java/.../
│ │ ├── ExpoSpatialLayerModule.kt # Expo Module
│ │ ├── ExpoSpatialLayerView.kt # MapView + TileOverlay
│ │ └── SpatialTileProvider.kt # Tile generator
│ └── cpp/
│ └── expo-spatial-layer.cpp # JNI Bridge
└── ios/
├── ExpoSpatialLayerModule.swift
└── ExpoSpatialLayerView.swift # MapKit implementation
┌─────────────────────────────────────────────────────────────┐
│ JAVASCRIPT LAYER │
├─────────────────────────────────────────────────────────────┤
│ │
│ spatial-test.tsx │
│ │ │
│ ├── getSpatialLayer().loadData(data) // Load data │
│ │ │
│ └── <SpatialMapView /> // Render map │
│ │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ EXPO MODULE LAYER │
├─────────────────────────────────────────────────────────────┤
│ │
│ ExpoSpatialLayerModule.kt │
│ │ │
│ ├── nativeInstall() → Exposes SpatialLayer host object │
│ └── native methods → Direct C++ calls via JNI │
│ │
│ ExpoSpatialLayerView.kt │
│ │ │
│ ├── GoogleMap setup │
│ ├── TileOverlay with SpatialTileProvider │
│ └── Camera listeners │
│ │
└──────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ C++ LAYER (JSI) │
├─────────────────────────────────────────────────────────────┤
│ │
│ SpatialLayer.cpp │
│ │ │
│ ├── QuadTree m_quadTree // O(log n) indexing │
│ │ │
│ ├── loadData(data) │
│ │ └── Normalized Web Mercator projection │
│ │ │
│ ├── getPointsForTile(tileX, tileY, zoom) │
│ │ ├── Calculates tile bounds │
│ │ ├── QuadTree query │
│ │ └── Returns [x, y, type] normalized │
│ │ │
│ └── findPointAt(lat, lon, tolerance) │
│ └── Radial query for picking │
│ │
└─────────────────────────────────────────────────────────────┘
The heart of the module resides in C++ to ensure raw performance and memory safety.
-
QuadTree Indexing: We implemented a QuadTree for
$O(\log n)$ spatial search. When loading data, points are projected to Web Mercator and inserted into the tree. - Performance: Capable of indexing 200k+ points in ~50ms and performing tile queries in <2ms.
The engine is data-agnostic.
- Metadata: Each point has an associated
type. - Dynamic Configuration: JS passes a
Record<number, number>map (Type -> Hex Color) via thepointStylesprop. - Pipeline: The native
SpatialTileProviderretrieves colors dynamically during bitmap drawing, allowing map appearance changes without reloading data.
- Zero-Latency Picking: When clicking the map,
ExpoSpatialLayerViewsends coordinates to C++ via JNI. - Spatial Query: The QuadTree performs a radial query (with tolerance) to find the ID of the clicked object.
- Event Flow: The result is sent back to JS via
onPointClick, triggering the callback with all selected point metadata.
To avoid JSON parsing overhead, we use a binary buffer (Float32Array[lat, lon, id, type]).
JS Float32Array
│
▼ (JSI Direct Access)
C++ loadData()
│
├── Project to Web Mercator [0,1]
└── Insert into QuadTree
| Operation | Previous Time | Current Time (QuadTree) |
|---|---|---|
| loadData() | ~150ms (JSON) | ~40ms (Binary) |
| Tile Query (Zoom 10) | ~15ms (Linear) | <1ms |
| Point Click (Picking) | N/A | <0.5ms |
- Implement Spatial Index (Quadtree)
- Hit-testing for point interaction (onPointClick)
- Agnostic dynamic styling (pointStyles)
- Full iOS Parity: Porting the view layer to
MKTileOverlay. - Dynamic Clustering:
$O(n)$ implementation for low-zoom legibility. - Configurable Sizing: Radius control per point type from JS.
- Heatmap Mode: Density-based rendering using GPU shaders.
- Web Support: QuadTree integration via WASM.