Integrating Indoor Maps with React Native SDKs
This page covers the one integration step that React Native makes uniquely hard — moving a precompiled indoor routing graph from a network payload into a native rendering and routing engine without freezing the UI thread — and it sits inside the SDK Integration Patterns workflow, immediately after version negotiation has decided which floor-level artifact the device should hold.
What “Integrating an Indoor Map into React Native” Means
In a React Native indoor app, the map is never a JavaScript component. Rasterising tiles, indexing a directed routing graph of thousands of nodes, and running A* on every position update are all O(V + E) workloads that would block the single JS thread and stall touch handling. The integration is therefore a bridge contract: a native module (a UIView/MKMapView subclass on iOS, a MapView/GLSurfaceView on Android, wrapping a C++ or Rust routing core) owns the graph and the render surface, and the JS layer only sends commands and receives events.
“Hydration” is the load-bearing term. It means deserialising a validated GeoJSON FeatureCollection into the native engine’s internal adjacency structures, building it in a staging slot, and promoting the engine reference only on success — so a half-parsed graph never serves a query. The graph itself is already trusted: it passed the contract defined in JSON Schema Design for Indoor Maps and the gates in CI Gating for Map Updates before it was ever published, and its geometry is expressed in the building’s local Indoor Coordinate Reference System. The bridge re-validates only a thin envelope, then hydrates.
Minimal Working Example: Gate the Envelope Before Hydration
Before any bytes cross the bridge, confirm the payload is the canonical envelope the native engine expects: a GeoJSON FeatureCollection with a metadata block carrying map_version, schema_revision, floor_level, and a content-addressed topology_hash. Rejecting a bad envelope here turns a silent on-device “no route” into a loud, logged failure at the boundary.
import logging
from typing import Any
from pydantic import BaseModel, Field, ValidationError, field_validator
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
SUPPORTED_REVISIONS: frozenset[str] = frozenset({"v1.2.0", "v1.3.0"})
class EnvelopeMetadata(BaseModel):
map_version: str
schema_revision: str
floor_level: int
topology_hash: str = Field(..., min_length=8)
coordinate_system: str
@field_validator("schema_revision")
@classmethod
def supported(cls, v: str) -> str:
if v not in SUPPORTED_REVISIONS:
raise ValueError(f"unsupported schema_revision: {v}")
return v
class MapEnvelope(BaseModel):
type: str = Field(..., pattern="^FeatureCollection$")
metadata: EnvelopeMetadata
features: list[dict[str, Any]]
def gate_envelope(raw: dict[str, Any]) -> MapEnvelope:
"""Validate a GeoJSON map envelope before it is handed to the native bridge."""
try:
env = MapEnvelope(**raw)
except ValidationError as exc:
logger.error("envelope rejected before hydration: %s", exc)
raise
logger.info("envelope ok: floor=%d hash=%s", env.metadata.floor_level, env.metadata.topology_hash[:12])
return env
The Native Bridge: Hydrating Off the JS Thread
The JS side of the bridge does four things in order: send a conditional fetch keyed on the topology_hash it already holds, stage the returned graph natively, promote on success, and surface routing through a thin async facade. The native side does the expensive work. Promote-on-success is what keeps an in-progress route alive: the live engine is replaced only when a complete new one exists.
import { NativeModules } from "react-native";
interface WayfindingEngine {
topologyHash: string;
computeRoute: (start: string, end: string) => Promise<string[]>;
}
export async function syncEngine(
endpoint: string,
floorLevel: number,
current?: WayfindingEngine,
): Promise<WayfindingEngine> {
const headers: Record<string, string> = { "X-Schema-Revision": "v1.3.0" };
if (current) headers["If-None-Match"] = current.topologyHash; // negotiate, do not re-download
const res = await fetch(`${endpoint}/maps/${floorLevel}/latest`, { headers });
if (res.status === 304 && current) return current; // already current
if (res.status === 409) throw new Error("SCHEMA_INCOMPATIBLE: client build is stale");
if (!res.ok) throw new Error(`map fetch failed: ${res.status}`);
const envelope = await res.json();
// Build into a native staging slot; a half-loaded graph must never serve a query.
const staged: boolean = await NativeModules.WayfindingBridge.loadGraph(envelope);
if (!staged) throw new Error("native hydration failed");
return {
topologyHash: envelope.metadata.topology_hash,
computeRoute: (s, e) => NativeModules.WayfindingBridge.computePath(s, e),
};
}
NativeModules.WayfindingBridge.loadGraph resolves a promise from the native thread (or a TurboModule on the New Architecture), so the parse never touches the JS event loop. State changes — position updates, POI highlights, a recompute onto a new node — flow back the other way through a NativeEventEmitter, never as synchronous return values.
Coordinate Calibration: Aligning the Native Canvas
CAD-exported floor plans arrive in millimetre-scale engineering coordinates; the native render surface expects the building’s bounded local Cartesian frame. Apply a 2D affine transform built from at least three non-collinear control points before the geometry is serialised into the envelope, so every device renders and routes in one consistent reference frame and the topology_hash stays stable across rebuilds.
import logging
import numpy as np
logger = logging.getLogger(__name__)
def compute_affine(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
"""Solve a 3x3 affine matrix mapping CAD coordinates (src) to the SDK canvas (dst)."""
if src.shape[0] < 3:
raise ValueError("at least 3 non-collinear control points are required")
a = np.hstack([src, np.ones((src.shape[0], 1))])
try:
mx, *_ = np.linalg.lstsq(a, dst[:, 0], rcond=None)
my, *_ = np.linalg.lstsq(a, dst[:, 1], rcond=None)
except np.linalg.LinAlgError as exc:
logger.error("affine solve failed (degenerate control points?): %s", exc)
raise
matrix = np.vstack([mx, my, [0.0, 0.0, 1.0]])
logger.info("affine residual scale=%.4f", float(matrix[0, 0]))
return matrix
Export the resulting calibrated extent as viewport_bounds on the envelope so the native tile loader, pinch-to-zoom handler, and routing core all share one bounded frame.
Bridge Contract Reference
| Field / call | Type | Default | Notes |
|---|---|---|---|
metadata.topology_hash |
string (≥8) |
— | Content-addressed graph identity; sent as If-None-Match for negotiation |
metadata.schema_revision |
string |
— | Must be in the device’s supported set or the middleware returns 409 |
metadata.floor_level |
int |
— | Z encodes the floor level; one engine per active/adjacent floor |
X-Schema-Revision (request header) |
string |
— | The build’s max supported revision; drives the compatibility check |
loadGraph(envelope) |
Promise<boolean> |
— | Builds the routing graph in a native staging slot; resolves true on success |
computePath(start, end) |
Promise<string[]> |
— | A* over the hydrated graph on the native thread; returns ordered node ids |
viewport_bounds |
[minX, minY, maxX, maxY] |
— | Calibrated extent from the affine transform; bounds tiles and zoom |
Common Errors & Fixes
No route found on a fully mapped floor. The device build predates the artifact’s schema_revision, so it deserialised the envelope without error but cannot read the renamed edge properties. Diagnose by comparing the X-Schema-Revision request header against the artifact’s schema_revision; fix it by negotiating at the middleware and returning 409 SCHEMA_INCOMPATIBLE rather than letting the client guess:
if (res.status === 409) {
// Surface an "update required" state — never hydrate a graph the build cannot parse.
throw new Error("SCHEMA_INCOMPATIBLE: prompt app update");
}
NSInvalidArgumentException / ClassCastException during deserialisation. The native parser hit a field type it did not expect (a coordinate sent as a string, or a null where a number was required). This is the symptom of skipping the envelope gate. Run gate_envelope at the boundary so a malformed payload is rejected before it crosses the bridge, not after the native thread has already thrown.
Blue dot teleports after a background refresh. The engine reference was swapped while a route was active. Stage the new graph and promote it only at a safe decision point — when the user reaches the next node — so the in-progress route finishes on the graph it started on and reconciles to the new topology_hash at the next junction.
Integration Point
This page is one stage of a longer delivery path. The envelope it hydrates is produced and signed off upstream by JSON Schema Design for Indoor Maps and the merge gates in CI Gating for Map Updates; the topology_hash the bridge sends as If-None-Match is the same content-addressed tag that drives Cache Invalidation Strategies, so the device and the edge agree on identity for free. When hydration or positioning fails, the bridge hands control to the degradation ladder described in Fallback Routing Architectures, and the degradation_stage telemetry it emits is what lets Rollback Triggers & Versioning auto-revert a release that is failing in the field.
Frequently Asked Questions
Why hydrate on the native thread instead of parsing in JavaScript?
Parsing a large floor-level routing graph and indexing it for A* is an O(V + E) workload; on React Native’s single JS thread it blocks touch handling and freezes the blue dot. Resolving loadGraph from a native promise (or a TurboModule) keeps the parse and every subsequent route computation off the event loop, so the UI stays responsive even while a new floor hydrates.
What is the minimum the bridge should re-validate on-device?
Only the thin envelope — type is FeatureCollection, the metadata block is present, and schema_revision is in the device’s supported set. Deep topology validation already ran once at the gate before promotion, so re-running it on the phone wastes battery and duplicates work. The device’s only deeper check is that the topology_hash it hydrated matches the one it requested.
How do I avoid corrupting an in-progress route when a new map publishes?
Never replace the live engine reference mid-step. Build the new graph in a native staging slot and promote it only at a decision point such as the next node. The active route continues on the graph it started with and reconciles to the new topology_hash at the next junction, so the position never jumps onto an edge that no longer exists.
Related
- SDK Integration Patterns for Indoor Maps — the version-negotiation and degradation contract this bridge plugs into.
- Designing JSON Schemas for Indoor Map APIs — the envelope contract the bridge re-validates before hydration.
- Implementing Cache Invalidation for Real-Time Updates — how the freshness events behind the topology_hash reach the device.
This page is part of the SDK Integration Patterns guide within the Production-Ready Indoor Map Deployment reference.