πΊοΈ Map & Geospatial β
The map tab uses @rnmapbox/maps v10 with Turf.js for geospatial calculations.
Token Setup β
Two Mapbox tokens are required:
| Token | Env Var | Type | Usage |
|---|---|---|---|
Public (pk.) | EXPO_PUBLIC_MAPBOX_ACCESS_TOKEN | Runtime | Map rendering |
Secret (sk.) | RNMAPBOX_MAPS_DOWNLOAD_TOKEN | Build-time only | Downloads Mapbox SDK during pod install / Gradle |
CAUTION
Never commit the secret sk. token. It is only needed in .env locally and in CI secrets β not in source code.
Layer Components (components/map/) β
| Component | Purpose |
|---|---|
map-view | Root MapboxGL.MapView wrapper with camera, gesture config |
geometry-polygons-layer | Renders polygon/multipolygon catalog geometries as filled shapes |
geometry-points-layer | Renders point catalog geometries (incl. reference records) as pins |
notes-layer | Renders note pins (location-tagged drafts) |
field-drawing-layer | In-progress polygon while drawing a new field (flag-gated) |
map-controls | Zoom, compass, and locate-me buttons |
map-home-drawer | Map entry drawer (workspace, "Call Tellia", quick nav) |
Render path: the dynamic layer catalog β
The map no longer renders from per-entity layers (fields, parcels, reference records). It renders from a dynamic layer catalog:
useGeoLayers(companyId)βGET /companies/:companyId/geo/layersenumerates every(workspace, geometry-property)layer that has geometry.- Per-layer visibility toggles (
useLayerToggles) persist the set of disabled keys, so a new layer defaults on. useGeoLayerFeatures(companyId, enabledLayers)fetches features per enabled layer by bbox, split into points/polygons.
Both queries have a 60s staleTime and are persisted to MMKV (24h). Consequence: after creating geometry (field/point/polygon), the mutation must invalidate queryKeys.geoLayers(companyId) and queryKeys.geoLayerFeatures.root or the new geometry won't appear until staleness expires or the screen remounts. The legacy geoFeatures query no longer feeds the map (kept only for the offline-pack bbox, the note field picker, and E2E dev-seed detection).
Tapping Features β
Tapping a field, note, or parcel opens the corresponding action sheet via SheetManager:
// Simplified from geometry-polygons-layer
onPress={(feature) => {
SheetManager.show('field-action-sheet', {
payload: { fieldId: feature.id },
});
}}NOTE
Add-to-map entry. AddToMapButton is shown whenever map overlays are (hidden mid-draw/placement) and enabled only when canAddToMap holds (company resolved, at least one actionable layer, not offline); it opens the draw/placement UI (map-drawing-toolbar, field-drawing-layer). A saved field/point/polygon appears on the map only after queryKeys.geoLayers + queryKeys.geoLayerFeatures are invalidated (see the catalog render path above) β useCreateField and the point/polygon save handlers all do this.
Offline Map Downloads β
Each workspace (company) gets its own offline map pack so field workers keep map tiles without a connection. The pack is named workspace-<companyId>, uses the SatelliteStreet style, and covers zoom 10β16.
What gets downloaded β
The download region is a bounding box derived from the workspace's field polygons (computeWorkspaceBbox). If the workspace has no field geometry yet, it falls back to ~10 km around the last cached camera position. With neither fields nor a cached camera, canDownload is false β there's nothing meaningful to cache (we never download a pack at [0,0]).
Auto-download vs manual β
- Auto: the first time a workspace is opened on WiFi, the pack downloads automatically (once per workspace). Cellular never auto-triggers.
- Manual: users manage it from Menu β Offline map (
app/(app)/(tabs)/menu/offline-map.tsx) β download, see progress / size, or remove the pack.
Engine & hook β
State lives in a single-instance engine (hooks/use-offline-map-pack-engine.ts) mounted once via OfflineMapPackProvider. Read it through useOfflineMapPack():
const {
status, // 'idle' | 'downloading' | 'complete' | 'error'
percentage, // download progress 0β100
sizeBytes, // bytes downloaded so far
isWifi, // gates auto-download
isInitialized, // false until the existing-pack lookup resolves
canDownload, // workspace has a usable bbox
download, // start / restart the pack
remove, // delete the pack
} = useOfflineMapPack();IMPORTANT
Never mount the engine twice. rnmapbox keeps one listener slot per pack name and its JS pack cache is add-only, so two instances race on delete/create and steal each other's progress events. Always go through OfflineMapPackProvider / useOfflineMapPack().
CAUTION
deletePack does not cancel an in-flight download (rnmapbox keeps the native task running; re-creating the same pack then fails with "Group load with same identifier started"). pause() is the real cancel β cancelPackDownload in lib/map/offline-pack.ts handles it. A region exceeding Mapbox's 750-tile cap is recoverable: it's reported to Sentry as a warning, not an error.
Parcel Loading β
Parcels are fetched by map bounding box to avoid loading the entire country dataset. The useParcels(bbox) hook debounces bounds changes by 500ms to avoid hammering the API while the user pans.
const [bbox, setBbox] = useState<BoundingBox | null>(null);
const { data: parcels } = useParcels(bbox);
// Update bbox in onMapIdle or onRegionDidChange (debounced externally)The bbox parameter is [minLng, minLat, maxLng, maxLat].
Turf.js Utilities (lib/map/) β
Common geospatial helpers used in the app:
import * as turf from '@turf/turf';
// Check if a point is inside a polygon (e.g. note inside field)
turf.booleanPointInPolygon(point, polygon);
// Calculate field area in hectares
const area = turf.area(polygon) / 10_000; // mΒ² β ha
// Get bounding box of a polygon
const [minLng, minLat, maxLng, maxLat] = turf.bbox(polygon);