Camera & Viewport
Controlled vs. uncontrolled viewport state, the useMap() camera API, and how mapcn prevents camera feedback loops.
Controlled vs. uncontrolled
Map decides its mode from whether the viewport prop is present:
- Uncontrolled (no
viewportprop):Mapowns its own camera state, seeded fromdefaultViewport. - Controlled (
viewportpresent): the camera always follows theviewportprop. Combine it withonViewportChangeto keep external state in sync.
const [viewport, setViewport] = useState({ center: [-122.4194, 37.7749], zoom: 12 });
<Map viewport={viewport} onViewportChange={setViewport} style="streets" />;MapViewport is { center: Coordinate; zoom: number; bearing: number; pitch: number }. Every prop and callback that isn't the full viewport (defaultViewport, viewport, the values passed to onViewportChange) accepts a Partial<MapViewport> -- you don't have to specify every field.
Loop prevention
A controlled camera has an obvious failure mode: a native camera event updates onViewportChange, which calls setState, which changes the viewport prop, which the map re-applies to the native camera, which re-fires the event. Map avoids this with three mechanisms:
- Echo suppression. Every native viewport event is compared against the last viewport
Mapitself pushed to the native camera, using an epsilon (1e-7for coordinates,1e-3for zoom/bearing/pitch -- seeviewportEqualsinlib/mapcn/geo.ts). A matching event is treated as an echo and does not re-invokeonViewportChange. - Prop-write suppression. When the
viewportprop changes,Maponly pushes it to the native camera if it differs (beyond the same epsilon) from the last value it reported out. This is what stopsonViewportChange → setState → viewport propfrom ping-ponging. - Gesture-active suppression. While the user is actively panning/zooming/rotating, prop-driven camera writes are held off until the gesture ends, so
Mapnever fights the user's finger.
onViewportChange fires continuously while the camera moves, throttled by viewportChangeThrottle (default 100ms, trailing edge). onViewportChangeEnd fires once after movement settles -- prefer it for expensive work (data refetches, heavy layer updates).
Imperative camera control
useMap() returns a MapInstance with the full camera API -- this is what MapControls's zoom buttons and MapClusterLayer's tap-to-expand use internally:
function LocateButton() {
const map = useMap();
return (
<Pressable onPress={() => map.flyTo([-122.4194, 37.7749], { zoom: 14, duration: 800 })}>
<Text>Fly to San Francisco</Text>
</Pressable>
);
}flyTo/moveTo/zoomTo/zoomBy/fitBounds/fitFeatures/resetNorth all accept an optional MapCameraAnimation ({ duration?, easing?, padding? }). fitFeatures computes the bounding box of arbitrary GeoJSON input and calls fitBounds for you.
For imperative reads, getViewport(), project(), and unproject() are all async -- they round-trip to the native map.