Skip to content

πŸ—ΊοΈ Map & Geospatial ​

The map tab uses @rnmapbox/maps v10 with Turf.js for geospatial calculations.

Token Setup ​

Two Mapbox tokens are required:

TokenEnv VarTypeUsage
Public (pk.)EXPO_PUBLIC_MAPBOX_ACCESS_TOKENRuntimeMap rendering
Secret (sk.)RNMAPBOX_MAPS_DOWNLOAD_TOKENBuild-time onlyDownloads 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/) ​

ComponentPurpose
map-viewRoot MapboxGL.MapView wrapper with camera, gesture config
geometry-polygons-layerRenders polygon/multipolygon catalog geometries as filled shapes
geometry-points-layerRenders point catalog geometries (incl. reference records) as pins
notes-layerRenders note pins (location-tagged drafts)
field-drawing-layerIn-progress polygon while drawing a new field (flag-gated)
map-controlsZoom, compass, and locate-me buttons
map-home-drawerMap 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/layers enumerates 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:

tsx
// 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():

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

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

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