diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e428d..d6b55f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,11 @@ frames after the inner ones; no frame waits for all of them. ### Added +- `onCameraMove` and `cameraMoveThrottleMs`: an opt-in, throttled stream of the camera + while it moves, for overlays that follow the map. Nothing runs unless it is set. +- `react-native-better-maps/reanimated` with `useCameraSharedValue`, which feeds that + stream into a Reanimated shared value; `react-native-reanimated` is an optional peer + dependency. - `pinStyle` prop (`'flat' | 'system'`) for the Apple provider. - `MarkerCollection` and `useMarkerCollection`: a native-owned marker dataset updated through `set`, `upsert`, `remove` and `updatePositions`, passed to `MapView` with the diff --git a/README.md b/README.md index bf8636a..10c1802 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,40 @@ function ControlledMap() { } ``` +### Following the camera + +`onRegionChange` and `onRegionChangeComplete` fire once per gesture, which is what data loading wants. An overlay that must track the camera while it moves opts into a throttled stream: + +```tsx + setHeading(camera.heading ?? 0)} + cameraMoveThrottleMs={100} +/> +``` + +Nothing runs unless `onCameraMove` is set, and each call crosses to the JS thread, so pair a low throttle with a cheap handler. With Reanimated installed, `react-native-better-maps/reanimated` feeds the stream into a shared value that overlays read on the UI thread without a React render per update: + +```tsx +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import { useCameraSharedValue } from 'react-native-better-maps/reanimated'; + +function MapWithCompass() { + const { camera, onCameraMove } = useCameraSharedValue(); + const needle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${-(camera.value?.heading ?? 0)}deg` }], + })); + + return ( + <> + + + + ); +} +``` + +`react-native-reanimated` is an optional peer dependency; the main entry point does not import it. + ## Map providers `MapView` accepts an optional `provider` prop: @@ -627,6 +661,7 @@ setMarkers((current) => | Scale control | Supported | Unsupported | Unsupported | | Markers / overlays | Supported | Supported | Supported | | Marker collections (deltas) | Supported | Supported | Supported | +| Camera stream (`onCameraMove`) | Supported | Supported | Supported | | Pin style | `flat` (default) or `system` | Google default marker | Google default marker | | Custom marker images | Supported | Supported | Supported | | Marker callouts / dragging | Supported | Supported | Supported | @@ -658,6 +693,7 @@ setMarkers((current) => | --------------------- | ------------------------------------------------------------------------ | | `MarkerCollection` | Native-owned marker dataset updated through `set` / `upsert` / `remove` / `updatePositions` | | `useMarkerCollection` | Creates one `MarkerCollection` for the lifetime of a component | +| `useCameraSharedValue` | From `react-native-better-maps/reanimated`: feeds `onCameraMove` into a Reanimated shared value | ### Types diff --git a/bun.lock b/bun.lock index 7df4891..c4b7896 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,8 @@ "react-native": "0.86.0", "react-native-builder-bob": "^0.43.0", "react-native-nitro-modules": "^0.35.10", + "react-native-reanimated": "4.5.0", + "react-native-worklets": "0.10.0", "release-it": "^19.0.0", "typescript": "^5.8.3", }, @@ -60,7 +62,11 @@ "react": "*", "react-native": ">=0.78.0", "react-native-nitro-modules": ">=0.35.0", + "react-native-reanimated": ">=3.0.0", }, + "optionalPeers": [ + "react-native-reanimated", + ], }, }, "overrides": { diff --git a/docs/adr/0007-camera-stream-and-cpp-core.md b/docs/adr/0007-camera-stream-and-cpp-core.md new file mode 100644 index 0000000..b7fd4af --- /dev/null +++ b/docs/adr/0007-camera-stream-and-cpp-core.md @@ -0,0 +1,62 @@ +# ADR 0007: Camera stream, Reanimated binding, and the shared C++ core + +## Status + +Accepted + +## Context + +The performance audit's last phase listed three optional items: an opt-in stream of the +camera while it moves, a Reanimated binding for overlays that follow the map, and a shared +C++ core for the marker store, index and clustering, the last one only "if profiling after +phase 3 shows Kotlin or Swift compute as the limiter". + +The camera reaches JS twice per gesture (`onRegionChange`, `onRegionChangeComplete`), +which is right for data loading and wrong for a compass or a custom overlay that must track +the map: those had to poll `getCamera()`, a three-hop promise per call. + +## Decision + +- **`onCameraMove` and `cameraMoveThrottleMs`.** While the camera moves the adapter emits + the camera at most every `cameraMoveThrottleMs` (default 100 ms) and once more when it + stops. MapKit samples the camera on a display link that runs only during the move; the + Google SDKs already report every frame and the adapter throttles. Nothing runs unless the + callback is set, so the idle map stays at zero work and the default map stays out of the + per-frame JS path. +- **`react-native-better-maps/reanimated`.** A separate entry point with + `useCameraSharedValue`, which returns a shared value and a stable `onCameraMove` handler + that writes into it. Overlays read the value in `useAnimatedStyle` and follow the camera on + the UI thread without a React render per update. `react-native-reanimated` is an optional + peer dependency; the main entry point does not import it. +- **Shared C++ core: not built.** The audit made it conditional on profiling showing + Swift or Kotlin compute as the limiter after the frame-budgeted pipeline. The signposts + from the 100,000-marker clustered scenario on the iPhone simulator put the whole compute + side (index query, clustering, diff) on the background queue at a p95 of 6.5 ms and a + maximum of 10 ms, and the main-thread apply at a maximum of 3.6 ms; the scenario that + still drops frames (10,000 markers inside one city viewport) spends up to 15 ms on the main + thread inside MapKit's annotation-view layout while its compute stays under 3.1 ms. + On the Android emulator the same 100,000-marker scenario holds a 17 ms p99 and a 33 ms worst frame, so Kotlin compute is not limiting frames there either. A C++ core would speed up the part that is + already off the main thread and already under a frame, and would leave the SDK view work + where it is. The store, index and cluster engine keep their two native implementations, + which share the packed batch format and the same test fixtures. The decision is revisited + if a future dataset or a device shows the background compute reaching the frame budget. + +## Consequences + +- A low throttle is a per-frame JS call. The Reanimated binding keeps the handler to one + assignment, which is the cheap end of what a per-frame call can do; a handler that sets + React state at 16 ms would re-render at 60 Hz. +- The stream reports the camera the SDK reports. On MapKit that is `MKMapView.camera` at + the display link's tick; during an animated camera change it follows the animation. +- Two entry points means two type roots in `lib/typescript`; `react-native-builder-bob` + compiles the whole `src` tree, so nothing changes in the build. + +## Alternatives considered + +- **A per-frame native binding to Reanimated's worklet runtime.** Would move the camera + into a shared value without touching the JS thread at all, at the cost of coupling the + native code to Reanimated's internal runtime API, which changes between major versions. + The JS-side binding costs one assignment per update and works with any Reanimated 3 or 4. +- **Reporting region instead of camera.** Region is the SDK's own derivation and diverges + between MapKit and Google when the map is tilted or rotated; overlays want heading, pitch + and zoom, which only the camera carries. diff --git a/docs/architecture.md b/docs/architecture.md index e9dee1f..35dfe70 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,6 +69,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C | Callback | Payload | Notes | | ------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `onRegionChange` / `onRegionChangeComplete` | `Region` | iOS uses `MKCoordinateRegion` (center + span); Android derives center + deltas from visible `LatLngBounds`. Values agree without rotation/pitch but may diverge when the map is tilted or rotated. | +| `onCameraMove` | `Camera` | Opt-in stream while the camera moves, at most every `cameraMoveThrottleMs` (default 100) and once more when it stops. MapKit samples it on a display link; the Google SDKs report per frame and the adapter throttles. Nothing runs unless the callback is set. | | `onPress` / `onLongPress` | `Coordinate` | Map background only; marker taps do not also fire map `onPress`. | | `onPoiPress` | `PoiPressEvent` | Provider-owned base-map POIs only. Apple Maps emits category data; Google Maps emits place ID. POI taps do not also fire map `onPress`. | | `onMapReady` | none | Fires once after the map finishes loading tiles. | @@ -112,6 +113,8 @@ Markers take a different route, because their datasets are large and change ofte The diff does not reach the map SDK in one pass. A per-map scheduler driven by `CADisplayLink` on iOS and `Choreographer` on Android applies removals at once, then a bounded number of adds per frame, nearest to the camera first, then retained updates within a 2 ms budget; the add count halves after a long frame and grows back on frames within budget. A newer diff replaces whatever is still pending, which is safe because diffs are computed against what is actually on the map. On MapKit the live refresh during gestures runs off the same display link instead of a wall-clock timer, and image-less markers are flat pre-rendered pins unless `pinStyle="system"` asks for `MKMarkerAnnotationView`. Clustering keeps the buckets of the cells that were fully inside the previous padded viewport for as long as the zoom octave and the dataset stay the same, so a pan only accumulates the cells that entered. See [ADR 0006](adr/0006-frame-budgeted-rendering.md). +The camera reaches JS through two events per gesture, `onRegionChange` when it begins and `onRegionChangeComplete` when it ends, which suits data loading. Overlays that must track the map while it moves opt into `onCameraMove`: MapKit samples `MKMapView.camera` on a display link that runs only between `regionWillChange` and `regionDidChange`, the Google SDKs report the camera every frame and the adapter throttles it to `cameraMoveThrottleMs`, and every adapter emits the final camera once the move ends. The `react-native-better-maps/reanimated` entry point turns that stream into a Reanimated shared value so overlays follow the camera on the UI thread without a React render per update. See [ADR 0007](adr/0007-camera-stream-and-cpp-core.md). + Marker and marker-cluster entering animations follow the same descriptor model. The public API accepts `false`, `system`, or a serializable preset config; the React wrapper normalizes that into native descriptors. Native provider adapters execute the animation when a marker render element appears in the render diff. Updating animation config for an already retained marker does not restart the animation; the new config is used the next time that marker is added again. Google Maps SDKs are sensitive to marker animation churn. Large viewport refreshes can add many native marker instances on the main thread, so the Google provider limits how many markers animate per refresh and reveals the rest immediately. This keeps gestures responsive, but very large marker sets may still need clustering, disabled entering animations, or a future provider-specific animation strategy. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 1d77e3d..d1a89be 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -35,22 +35,24 @@ They are implemented in `benchmark/thresholds.ts` and unit-tested with ## Scenarios -| ID | Setup | Script | -| --- | ---------------------------------- | --------------------------------------------------------------------------------- | -| A | empty map | 3 s idle, short pan | -| B | 100 markers | pan | -| C | 1,000 markers | pan | -| D | 10,000 markers | pan | -| E | 10,000 markers, clustering on | zoom sweep across five levels, then pan | -| F | 10,000 markers | ten-leg pan | -| G | 10,000 markers | zoom sweep | -| H | 10,000 markers | four heading changes | -| I | 1,000 markers in a collection | 100 of them move at 10 Hz for 5 s through `updatePositions`; JS lag is checked | -| I2 | 1,000 markers | 100 of them move at 10 Hz for 5 s through new `markers` arrays; JS lag is checked | -| K | 5,000-point route and 200 polygons | five style changes, then pan | -| L | 10,000 markers | three pan legs, then 5 s idle | -| M | 10,000 markers in a collection | one marker is upserted every 100 ms for 3 s; JS lag is checked | -| N | 10,000 markers inside the viewport | street-level zoom sweep, where the LOD cap allows 2,000 markers on screen | +| ID | Setup | Script | +| --- | ---------------------------------- | ------------------------------------------------------------------------------------ | +| A | empty map | 3 s idle, short pan | +| B | 100 markers | pan | +| C | 1,000 markers | pan | +| D | 10,000 markers | pan | +| E | 10,000 markers, clustering on | zoom sweep across five levels, then pan | +| F | 10,000 markers | ten-leg pan | +| G | 10,000 markers | zoom sweep | +| H | 10,000 markers | four heading changes | +| I | 1,000 markers in a collection | 100 of them move at 10 Hz for 5 s through `updatePositions`; JS lag is checked | +| I2 | 1,000 markers | 100 of them move at 10 Hz for 5 s through new `markers` arrays; JS lag is checked | +| K | 5,000-point route and 200 polygons | five style changes, then pan | +| L | 10,000 markers | three pan legs, then 5 s idle | +| M | 10,000 markers in a collection | one marker is upserted every 100 ms for 3 s; JS lag is checked | +| N | 10,000 markers inside the viewport | street-level zoom sweep, where the LOD cap allows 2,000 markers on screen | +| O | 10,000 markers | pan while `onCameraMove` feeds a shared value at a 16 ms throttle; JS lag is checked | +| P | 100,000 markers, clustering on | zoom sweep across five levels, then pan | Scenario J (live location) is not scripted: it needs location permission and a GPS feed. Use the simulator's location menu with the manual recorder. @@ -352,6 +354,98 @@ N keeps a 67 ms worst frame at the octave crossings. - M-one-of-10k: JS lag p95 19.08 ms > budget 17.50 ms - N-dense-10k: p99 33.33 ms > 25.00 ms; worst frame 66.67 ms > 50.00 ms; jank 2.01% > 1% +### Camera stream and 100k runs (not a device baseline) + +The same simulator and emulator after ADR 0007. Two scenarios are new: +O pans a map of 10,000 markers with `onCameraMove` set and +`cameraMoveThrottleMs: 16`, so the callback fires every frame into a +Reanimated shared value; P mounts 100,000 clustered markers over Poland and +runs a zoom sweep and a pan. The older scenarios moved within run-to-run +noise of the previous section (F's p99 sits one frame over the threshold in +this run and passed in the last one; N's worst frame is 50 ms against 46 ms). + +**iOS**, iPhone 17 Pro simulator, release build, MapKit, 60 Hz, started by +hand, recorded 2026-09-08. O passes with a one-frame p99 and a JS-lag p95 of +1.0 ms while the camera callback ran 266 times during the pan (counted in a separate +run of O on the same build, after the note line was routed to the system log), +so a per-frame stream that only writes a shared value costs nothing the +harness can see. P holds one frame at p95 and two at p99 with 100,000 clustered markers, a +46 ms worst frame at the first octave crossing, 1.5 % jank and 150 MB of RSS +for the dataset. + +| Scenario | Result | FPS | p50 | p95 | p99 | Worst | Jank | JS lag p95 | RSS Δ | +| --------------------- | -------- | --- | ------- | ------- | ------- | ----- | ----- | ---------- | ------- | +| A-empty-idle | fail (1) | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 51 ms | 0.9 % | 1.3 ms | +75 MB | +| B-markers-100 | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 41 ms | 1.0 % | 1.1 ms | +84 MB | +| C-markers-1k | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 41 ms | 1.0 % | 1.0 ms | +73 MB | +| D-markers-10k | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 45 ms | 0.7 % | 1.0 ms | +67 MB | +| E-clustered-10k | pass | 59 | 16.7 ms | 16.7 ms | 21.5 ms | 47 ms | 1.0 % | 1.0 ms | +142 MB | +| F-pan-10k | fail (2) | 59 | 16.7 ms | 16.7 ms | 27.6 ms | 39 ms | 1.2 % | 1.0 ms | +84 MB | +| G-zoom-10k | fail (2) | 58 | 16.7 ms | 16.7 ms | 35.5 ms | 38 ms | 3.7 % | 1.0 ms | +101 MB | +| H-rotate-10k | fail (2) | 58 | 16.7 ms | 16.7 ms | 43.7 ms | 48 ms | 2.1 % | 1.0 ms | +56 MB | +| I-animated-collection | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 35 ms | 0.3 % | 1.3 ms | +11 MB | +| I2-animated-prop | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.0 ms | -1 MB | +| K-shapes | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 47 ms | 1.4 % | 1.3 ms | +73 MB | +| L-idle-after-pan | pass | 59 | 16.7 ms | 16.7 ms | 21.2 ms | 45 ms | 0.8 % | 1.0 ms | +65 MB | +| M-one-of-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 1.0 ms | -0 MB | +| O-camera-stream | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 46 ms | 0.7 % | 1.0 ms | +76 MB | +| P-clustered-100k | fail (2) | 59 | 16.7 ms | 16.7 ms | 33.3 ms | 46 ms | 1.5 % | 1.0 ms | +150 MB | +| N-dense-10k | fail (3) | 56 | 16.7 ms | 33.3 ms | 41.6 ms | 50 ms | 7.3 % | 1.0 ms | +122 MB | + +- A-empty-idle: worst frame 50.88 ms > 50.00 ms +- F-pan-10k: p99 27.62 ms > 25.00 ms; jank 1.21% > 1% +- G-zoom-10k: p99 35.46 ms > 25.00 ms; jank 3.69% > 1% +- H-rotate-10k: p99 43.73 ms > 25.00 ms; jank 2.07% > 1% +- K-shapes: p99 33.33 ms > 25.00 ms; jank 1.44% > 1% +- P-clustered-100k: p99 33.33 ms > 25.00 ms; jank 1.54% > 1% +- N-dense-10k: p95 33.33 ms > budget 17.50 ms; p99 41.56 ms > 25.00 ms; jank 7.34% > 1% + +The signposts recorded during the same run, per scenario, say where the time +goes. Decoding and indexing the 100,000-marker batch took 26.6 ms once, on the +store queue. Computing the viewport diff for P (the index query, clustering +through the octave cache, the diff against the screen) ran 89 times on the +background queue at a p50 of 0.85 ms, a p95 of 6.5 ms and a maximum of +10.0 ms. Applying those diffs on the main thread, which is MapKit adding and +removing annotation views under the frame budget, ran 110 times at a p50 of +0.47 ms, a p95 of 3.2 ms and a maximum of 3.6 ms. In N, the scenario that +still drops frames, the compute side stays under 3.1 ms while the main-thread +apply reaches 15 ms: the frames go to MapKit laying out the views, not to +Swift. At 10,000 markers every compute interval stays under 1 ms. + +**Android**, Pixel-class API 35 emulator (`TapNote_API35`), release build, +Google Maps, 60 Hz, driven by the Maestro flow, recorded 2026-09-08. Every +scenario holds one frame at p99. P keeps a 17 ms p99 and a 33 ms worst frame +with 100,000 clustered markers, and O stays at 17 ms with the callback firing +every frame (211 calls during the pan, counted in a second run of the flow on +the same build). N, which failed with a 67 ms worst frame on the previous build's +run, passes here at 17 ms; the 18 to 19 ms JS-lag column is the emulator's +timer resolution, as in the previous sections, and is what fails I, I2, M and +O. + +| Scenario | Result | FPS | p50 | p95 | p99 | Worst | Jank | JS lag p95 | RSS Δ | +| --------------------- | -------- | --- | ------- | ------- | ------- | ----- | ----- | ---------- | ------ | +| A-empty-idle | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.3 % | 18.8 ms | -24 MB | +| B-markers-100 | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.9 ms | -16 MB | +| C-markers-1k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.5 ms | +28 MB | +| D-markers-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.8 ms | +35 MB | +| E-clustered-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 0.2 % | 18.5 ms | +24 MB | +| F-pan-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.3 ms | -29 MB | +| G-zoom-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.4 ms | +19 MB | +| H-rotate-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 17.8 ms | -31 MB | +| I-animated-collection | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.7 ms | -86 MB | +| I2-animated-prop | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.6 ms | -56 MB | +| K-shapes | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.8 ms | +58 MB | +| L-idle-after-pan | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 19.3 ms | -54 MB | +| M-one-of-10k | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.8 ms | -18 MB | +| O-camera-stream | fail (1) | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 18.1 ms | +58 MB | +| P-clustered-100k | pass | 59 | 16.7 ms | 16.7 ms | 16.7 ms | 33 ms | 1.0 % | 18.8 ms | -35 MB | +| N-dense-10k | pass | 60 | 16.7 ms | 16.7 ms | 16.7 ms | 17 ms | 0.0 % | 19.1 ms | -17 MB | + +- I-animated-collection: JS lag p95 18.68 ms > budget 17.50 ms +- I2-animated-prop: JS lag p95 18.60 ms > budget 17.50 ms +- M-one-of-10k: JS lag p95 18.79 ms > budget 17.50 ms +- O-camera-stream: JS lag p95 18.07 ms > budget 17.50 ms + ## Profiling markers The library emits `os_signpost` intervals (iOS, subsystem `com.nitromaps`, diff --git a/example/App.tsx b/example/App.tsx index 89f146d..785f741 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -36,9 +36,12 @@ import Animated, { withSequence, withSpring, withTiming, + type SharedValue, } from 'react-native-reanimated'; +import { useCameraSharedValue } from 'react-native-better-maps/reanimated'; import { MapView, + type Camera, type ClusterPressEvent, type Coordinate, type EdgePadding, @@ -111,6 +114,32 @@ const springSoft = { damping: 20, stiffness: 240 }; const AnimatedPressable = Animated.createAnimatedComponent(Pressable); +/** A north indicator that counter-rotates with the map heading. */ +function CameraCompass({ + camera, + topInset, +}: { + camera: SharedValue; + topInset: number; +}) { + const needleStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${-(camera.value?.heading ?? 0)}deg` }], + })); + + return ( + + + ▲ + + N + + ); +} + function mergeMapPadding( padding: EdgePadding | undefined, showsScale: boolean, @@ -503,6 +532,7 @@ type MapSceneProps = { onClusterPress: (event: ClusterPressEvent) => void; onMarkerPress: (id: string) => void; onMarkerDragEnd: (id: string, coordinate: Coordinate) => void; + onCameraMove: (camera: Camera) => void; onOverlayPress: (label: string) => void; onPress: (coordinate: Coordinate) => void; onPoiPress: (event: PoiPressEvent) => void; @@ -522,6 +552,7 @@ const MapScene = memo(function MapScene({ onClusterPress, onMarkerPress, onMarkerDragEnd, + onCameraMove, onOverlayPress, onPress, onPoiPress, @@ -551,6 +582,10 @@ const MapScene = memo(function MapScene({ ), onMapReady, onClusterPress, + onMarkerPress, + onMarkerDragEnd, + onCameraMove, + cameraMoveThrottleMs: 16, onPress, onPoiPress, onLongPress, @@ -859,6 +894,10 @@ export default function App() { ); }, []); + // The camera stream feeds a shared value; the compass below follows the + // heading on the UI thread without a React render per update. + const { camera: cameraValue, onCameraMove } = useCameraSharedValue(); + return ( + + void; + cameraMoveThrottleMs?: number; +} + +/** Where scenario O parks the camera stream: a shared value, as an overlay would. */ +const cameraSink = makeMutable(null); +/** A free-form line next to the results: Metro in debug, the system log always. */ +async function note(text: string): Promise { + const line = `[benchmark-note] ${text}`; + console.log(line); + await logBenchmarkLine(line).catch(() => undefined); +} + +let cameraMoveCount = 0; +function sinkCameraMove(camera: Camera): void { + cameraSink.value = camera; + cameraMoveCount += 1; } /** What a scenario script can do while the recorder is running. */ @@ -311,6 +330,44 @@ export const SCENARIOS: BenchmarkScenario[] = [ }, ]; +SCENARIOS.push( + { + id: 'O-camera-stream', + name: 'O · Camera stream', + description: + 'Pan with 10,000 markers while onCameraMove feeds a shared value every frame (16 ms throttle).', + props: () => ({ + region: WARSAW_REGION, + markers: markers(10_000), + onCameraMove: sinkCameraMove, + cameraMoveThrottleMs: 16, + }), + settleMs: 2500, + checkJsLag: true, + async run(context) { + cameraMoveCount = 0; + await pan(context, WARSAW_REGION); + await note(`O-camera-stream: ${cameraMoveCount} camera updates`); + }, + }, + { + id: 'P-clustered-100k', + name: 'P · 100,000 clustered', + description: + '100,000 markers with clustering: zoom sweep across octaves, then a pan.', + props: () => ({ + region: POLAND_REGION, + markers: markers(100_000), + clusteringEnabled: true, + }), + settleMs: 6000, + async run(context) { + await zoomSweep(context, POLAND_REGION); + await pan(context, POLAND_REGION, 4, 0.4); + }, + }, +); + SCENARIOS.push({ id: 'N-dense-10k', name: 'N · Dense 10,000', diff --git a/example/maestro/benchmark-run-all.yaml b/example/maestro/benchmark-run-all.yaml index ece0545..9116593 100644 --- a/example/maestro/benchmark-run-all.yaml +++ b/example/maestro/benchmark-run-all.yaml @@ -12,9 +12,9 @@ appId: com.nitromaps.example timeout: 60000 - tapOn: id: 'benchmark-run-all' -# The summary reads "/14 passed" once every scenario has a result; the +# The summary reads "/16 passed" once every scenario has a result; the # last result row can sit below the fold of the results list. - extendedWaitUntil: visible: - text: '.*/14 passed' + text: '.*/16 passed' timeout: 300000 diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 1f17b1a..02fbc97 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -6,6 +6,7 @@ import android.content.pm.PackageManager import android.content.res.Configuration import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.view.View import android.view.ViewTreeObserver import androidx.annotation.Keep @@ -34,6 +35,8 @@ class GoogleMapProviderAdapter( private var googleMap: GoogleMap? = null private var isUserGesture = false + private var isCameraStreaming = false + private var lastCameraEmitMs = 0L private var hasFiredMapReady = false private val overlayController = MapOverlayController(null, context) private var pendingPolylines: Array? = null @@ -228,6 +231,8 @@ class GoogleMapProviderAdapter( override var onRegionChange: ((region: Region) -> Unit)? = null override var onRegionChangeComplete: ((region: Region) -> Unit)? = null + override var onCameraMove: ((camera: Camera) -> Unit)? = null + override var cameraMoveThrottleMs: Double? = null override var onMapReady: (() -> Unit)? = null override var onPress: ((coordinate: Coordinate) -> Unit)? = null override var onPoiPress: ((event: NativePoiPressEvent) -> Unit)? = null @@ -409,12 +414,15 @@ class GoogleMapProviderAdapter( handleRegionWillChange( userInteracting = reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE, ) + startCameraStream() } map.setOnCameraMoveListener { overlayController.onCameraMove() + emitCameraMoveIfDue(map) } map.setOnCameraIdleListener { overlayController.onCameraIdle() + stopCameraStream(map) handleRegionDidChange() } map.setOnMapClickListener { latLng -> @@ -689,6 +697,40 @@ class GoogleMapProviderAdapter( } } + /** + * `onCameraMove` while the camera moves, at most every `cameraMoveThrottleMs`, + * and once more with the final camera. Nothing runs unless it is set. + */ + private fun startCameraStream() { + if (onCameraMove == null) { + return + } + isCameraStreaming = true + lastCameraEmitMs = 0L + } + + private fun emitCameraMoveIfDue(map: GoogleMap) { + val callback = onCameraMove ?: return + if (!isCameraStreaming) { + return + } + val now = SystemClock.uptimeMillis() + val interval = (cameraMoveThrottleMs ?: DEFAULT_CAMERA_MOVE_THROTTLE_MS).coerceAtLeast(0.0).toLong() + if (lastCameraEmitMs != 0L && now - lastCameraEmitMs < interval) { + return + } + lastCameraEmitMs = now + callback(map.cameraPosition.toCamera()) + } + + private fun stopCameraStream(map: GoogleMap) { + if (!isCameraStreaming) { + return + } + isCameraStreaming = false + onCameraMove?.invoke(map.cameraPosition.toCamera()) + } + private fun handleRegionDidChange() { if (isUserGesture) { emitRegionChange(complete = true) @@ -733,6 +775,8 @@ class GoogleMapProviderAdapter( // view that is already gone. onRegionChange = null onRegionChangeComplete = null + onCameraMove = null + cameraMoveThrottleMs = null onMapReady = null onPress = null onPoiPress = null @@ -773,3 +817,5 @@ private fun emptyVisibleRegion(): VisibleRegion { val zero = Coordinate(latitude = 0.0, longitude = 0.0) return VisibleRegion(nearLeft = zero, nearRight = zero, farLeft = zero, farRight = zero) } + +private const val DEFAULT_CAMERA_MOVE_THROTTLE_MS = 100.0 diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index 679b2f6..7ae5581 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -192,6 +192,20 @@ class HybridMapView(private val context: ThemedReactContext) : adapter?.onRegionChangeComplete = value } + override var onCameraMove: ((camera: Camera) -> Unit)? = null + set(value) { + field = value + adapter?.onCameraMove = value + } + + private var _cameraMoveThrottleMs: Double? = null + override var cameraMoveThrottleMs: Double? + get() = _cameraMoveThrottleMs + set(value) { + _cameraMoveThrottleMs = value + adapter?.cameraMoveThrottleMs = value + } + override var onMapReady: (() -> Unit)? = null set(value) { field = value @@ -340,6 +354,8 @@ class HybridMapView(private val context: ThemedReactContext) : pinStyle = null onRegionChange = null onRegionChangeComplete = null + onCameraMove = null + _cameraMoveThrottleMs = null onMapReady = null onPress = null onPoiPress = null @@ -420,6 +436,8 @@ class HybridMapView(private val context: ThemedReactContext) : adapter.clusterEnteringAnimation = _clusterEnteringAnimation adapter.onRegionChange = onRegionChange adapter.onRegionChangeComplete = onRegionChangeComplete + adapter.cameraMoveThrottleMs = _cameraMoveThrottleMs + adapter.onCameraMove = onCameraMove adapter.onMapReady = onMapReady adapter.onPress = onPress adapter.onPoiPress = onPoiPress diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index 915794e..5aa63e7 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -26,6 +26,8 @@ interface MapProviderAdapter { var onRegionChange: ((region: Region) -> Unit)? var onRegionChangeComplete: ((region: Region) -> Unit)? + var onCameraMove: ((camera: Camera) -> Unit)? + var cameraMoveThrottleMs: Double? var onMapReady: (() -> Unit)? var onPress: ((coordinate: Coordinate) -> Unit)? var onPoiPress: ((event: NativePoiPressEvent) -> Unit)? diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index 6a7f49c..0ada0cd 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -7,6 +7,10 @@ final class AppleMapProviderAdapter: MapProviderAdapter { private var isUserRegionChange = false private var isMapReady = false private var hasDeliveredMapReady = false + private lazy var cameraStreamClock = FrameClock { [weak self] frame in + self?.cameraStreamTick(frame) + } + private var lastCameraEmitTime: CFTimeInterval = 0 fileprivate lazy var overlayController = MapOverlayController(mapView: view) var contentView: UIView { @@ -163,6 +167,14 @@ final class AppleMapProviderAdapter: MapProviderAdapter { var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? + var onCameraMove: ((Camera) -> Void)? { + didSet { + if onCameraMove == nil { + cameraStreamClock.stop() + } + } + } + var cameraMoveThrottleMs: Double? var onMapReady: (() -> Void)? { didSet { deliverMapReadyIfPossible() @@ -301,6 +313,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { func handleRegionWillChange(userInteracting: Bool) { startLiveClustering() + startCameraStream() guard userInteracting, !isUserRegionChange else { return } @@ -310,6 +323,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { func handleRegionDidChange() { stopLiveClustering() + stopCameraStream() guard isUserRegionChange else { return @@ -332,6 +346,38 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } + /// Emits `onCameraMove` on a display link while the camera moves, at most + /// every `cameraMoveThrottleMs`, and once more with the final camera. Nothing + /// runs unless the callback is set. + private func startCameraStream() { + guard onCameraMove != nil, !cameraStreamClock.isRunning else { + return + } + lastCameraEmitTime = 0 + cameraStreamClock.start() + } + + private func stopCameraStream() { + guard cameraStreamClock.isRunning else { + return + } + cameraStreamClock.stop() + onCameraMove?(view.camera.toCamera()) + } + + private func cameraStreamTick(_ frame: FrameClock.Frame) { + guard let onCameraMove else { + cameraStreamClock.stop() + return + } + let interval = max(0, (cameraMoveThrottleMs ?? 100) / 1000) + guard frame.timestamp - lastCameraEmitTime >= interval else { + return + } + lastCameraEmitTime = frame.timestamp + onCameraMove(view.camera.toCamera()) + } + func startLiveClustering() { overlayController.beginLiveRefresh() } @@ -408,11 +454,14 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } func prepareForRecycle() { + cameraStreamClock.stop() isUserRegionChange = false isMapReady = false hasDeliveredMapReady = false onRegionChange = nil onRegionChangeComplete = nil + onCameraMove = nil + cameraMoveThrottleMs = nil onMapReady = nil onPress = nil onPoiPress = nil diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index ac3958a..f8a5a4a 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -14,6 +14,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { private var hasDeliveredMapReady = false private var isUserRegionChange = false private var isUserGestureMoving = false + private var isCameraStreaming = false + private var lastCameraEmitTime: CFTimeInterval = 0 private var lastLiveMarkerRefreshTime: CFTimeInterval = 0 private var myLocationObservation: NSKeyValueObservation? private weak var followedLocationMapView: GMSMapView? @@ -176,6 +178,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? + var onCameraMove: ((Camera) -> Void)? + var cameraMoveThrottleMs: Double? var onMapReady: (() -> Void)? { didSet { deliverMapReadyIfPossible() @@ -269,6 +273,10 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { func prepareForRecycle() { isUserRegionChange = false isUserGestureMoving = false + isCameraStreaming = false + lastCameraEmitTime = 0 + onCameraMove = nil + cameraMoveThrottleMs = nil lastLiveMarkerRefreshTime = 0 lastAppliedRegion = nil lastAppliedRegionCamera = nil @@ -426,6 +434,37 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { lastLiveMarkerRefreshTime = 0 } + /// `onCameraMove` while the camera moves, at most every `cameraMoveThrottleMs`, + /// and once more with the final camera. Nothing runs unless it is set. + private func startCameraStream() { + guard onCameraMove != nil else { + return + } + isCameraStreaming = true + lastCameraEmitTime = 0 + } + + private func emitCameraMoveIfDue(_ position: GMSCameraPosition) { + guard isCameraStreaming, let onCameraMove else { + return + } + let now = CACurrentMediaTime() + let interval = max(0, (cameraMoveThrottleMs ?? 100) / 1000) + guard now - lastCameraEmitTime >= interval else { + return + } + lastCameraEmitTime = now + onCameraMove(position.toCamera()) + } + + private func stopCameraStream(at position: GMSCameraPosition) { + guard isCameraStreaming else { + return + } + isCameraStreaming = false + onCameraMove?(position.toCamera()) + } + private func animateToClusterRegion(_ region: MKCoordinateRegion) { let bounds = region.toRegion().toGMSCoordinateBounds() view.animate(with: GMSCameraUpdate.fit(bounds, withPadding: 72)) @@ -529,6 +568,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { extension GoogleMapProviderAdapter: GMSMapViewDelegate { func mapView(_ mapView: GMSMapView, willMove gesture: Bool) { handleRegionWillChange(userInteracting: gesture) + startCameraStream() if gesture { startGestureMarkerRefresh() } @@ -536,11 +576,13 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { refreshGestureMarkersIfNeeded() + emitCameraMoveIfDue(position) } func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) { refreshVisibleMarkers() stopGestureMarkerRefresh() + stopCameraStream(at: position) handleRegionDidChange() notifyMapReadyIfNeeded() } diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index 7804a19..cd15c59 100644 --- a/package/ios/HybridMapView.swift +++ b/package/ios/HybridMapView.swift @@ -181,6 +181,18 @@ final class HybridMapView: HybridMapViewSpec { } } + var onCameraMove: ((Camera) -> Void)? { + get { getBacked(\.onCameraMove) } + set { setBackedOnMain(newValue, store: \.onCameraMove) { $0.onCameraMove = $1 } } + } + + var cameraMoveThrottleMs: Double? { + get { getBacked(\.cameraMoveThrottleMs) } + set { + setBackedOnMain(newValue, store: \.cameraMoveThrottleMs) { $0.cameraMoveThrottleMs = $1 } + } + } + var onMapReady: (() -> Void)? { get { getBacked(\.onMapReady) } set { setBackedOnMain(newValue, store: \.onMapReady) { $0.onMapReady = $1 } } diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift index 141e259..2088a3d 100644 --- a/package/ios/MapProviderAdapter.swift +++ b/package/ios/MapProviderAdapter.swift @@ -25,6 +25,8 @@ protocol MapProviderAdapter: AnyObject { var onRegionChange: ((Region) -> Void)? { get set } var onRegionChangeComplete: ((Region) -> Void)? { get set } + var onCameraMove: ((Camera) -> Void)? { get set } + var cameraMoveThrottleMs: Double? { get set } var onMapReady: (() -> Void)? { get set } var onPress: ((Coordinate) -> Void)? { get set } var onPoiPress: ((NativePoiPressEvent) -> Void)? { get set } @@ -76,6 +78,8 @@ final class UnavailableMapProviderAdapter: MapProviderAdapter { var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? + var onCameraMove: ((Camera) -> Void)? + var cameraMoveThrottleMs: Double? var onMapReady: (() -> Void)? var onPress: ((Coordinate) -> Void)? var onPoiPress: ((NativePoiPressEvent) -> Void)? diff --git a/package/ios/MapViewState.swift b/package/ios/MapViewState.swift index 3221653..0ed5c34 100644 --- a/package/ios/MapViewState.swift +++ b/package/ios/MapViewState.swift @@ -22,6 +22,8 @@ struct MapViewState { var pinStyle: MarkerPinStyle? var onRegionChange: ((Region) -> Void)? var onRegionChangeComplete: ((Region) -> Void)? + var onCameraMove: ((Camera) -> Void)? + var cameraMoveThrottleMs: Double? var onMapReady: (() -> Void)? var onPress: ((Coordinate) -> Void)? var onPoiPress: ((NativePoiPressEvent) -> Void)? @@ -58,6 +60,8 @@ struct MapViewState { adapter.pinStyle = pinStyle adapter.onRegionChange = onRegionChange adapter.onRegionChangeComplete = onRegionChangeComplete + adapter.cameraMoveThrottleMs = cameraMoveThrottleMs + adapter.onCameraMove = onCameraMove adapter.onMapReady = onMapReady adapter.onPress = onPress adapter.onPoiPress = onPoiPress diff --git a/package/package.json b/package/package.json index e4b7c1d..05b64c2 100644 --- a/package/package.json +++ b/package/package.json @@ -13,6 +13,11 @@ "import": "./lib/module/index.js", "default": "./lib/module/index.js" }, + "./reanimated": { + "source": "./src/reanimated/index.ts", + "types": "./lib/typescript/reanimated/index.d.ts", + "default": "./lib/module/reanimated/index.js" + }, "./app.plugin.js": "./app.plugin.js", "./package.json": "./package.json" }, @@ -83,7 +88,8 @@ "peerDependencies": { "react": "*", "react-native": ">=0.78.0", - "react-native-nitro-modules": ">=0.35.0" + "react-native-nitro-modules": ">=0.35.0", + "react-native-reanimated": ">=3.0.0" }, "devDependencies": { "@expo/config-plugins": "~57.0.0", @@ -95,6 +101,8 @@ "react-native": "0.86.0", "react-native-builder-bob": "^0.43.0", "react-native-nitro-modules": "^0.35.10", + "react-native-reanimated": "4.5.0", + "react-native-worklets": "0.10.0", "release-it": "^19.0.0", "typescript": "^5.8.3" }, @@ -115,5 +123,10 @@ } ] ] + }, + "peerDependenciesMeta": { + "react-native-reanimated": { + "optional": true + } } } diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx index 448dbe9..89972b2 100644 --- a/package/src/components/MapView.tsx +++ b/package/src/components/MapView.tsx @@ -97,6 +97,8 @@ export function MapView({ circles: circlesProp, onRegionChange, onRegionChangeComplete, + onCameraMove, + cameraMoveThrottleMs, onMapReady, onPress, onPoiPress, @@ -304,6 +306,7 @@ export function MapView({ const onRegionChangeCompleteCallback = useNitroCallback( onRegionChangeComplete, ); + const onCameraMoveCallback = useNitroCallback(onCameraMove); const onMapReadyCallback = useNitroCallback(onMapReady); const onPressCallback = useNitroCallback(onPress); const onPoiPressNativeCallback = useNitroCallback( @@ -382,6 +385,8 @@ export function MapView({ circles={circles} onRegionChange={onRegionChangeCallback} onRegionChangeComplete={onRegionChangeCompleteCallback} + onCameraMove={onCameraMoveCallback} + cameraMoveThrottleMs={cameraMoveThrottleMs} onMapReady={onMapReadyCallback} onPress={onPressCallback} onPoiPress={onPoiPressNativeCallback} diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts index bd4e208..3f644ea 100644 --- a/package/src/native/specs/MapView.nitro.ts +++ b/package/src/native/specs/MapView.nitro.ts @@ -198,6 +198,15 @@ export interface MapViewProps extends HybridViewProps { /** Called once when a user-initiated region change ends. */ onRegionChangeComplete?: (region: Region) => void; + /** + * Called while the camera moves, at most every `cameraMoveThrottleMs`, and + * once more when it stops. Opt-in: nothing runs unless it is set. + */ + onCameraMove?: (camera: Camera) => void; + + /** Minimum interval between `onCameraMove` calls, in milliseconds. */ + cameraMoveThrottleMs?: number; + /** Called when the map is ready to use. */ onMapReady?: () => void; diff --git a/package/src/reanimated/__tests__/cameraBinding.test.ts b/package/src/reanimated/__tests__/cameraBinding.test.ts new file mode 100644 index 0000000..fd6f4fc --- /dev/null +++ b/package/src/reanimated/__tests__/cameraBinding.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; +import type { Camera } from '../../types/camera'; +import { + createCameraBinding, + type WritableSharedValue, +} from '../cameraBinding'; + +describe('createCameraBinding', () => { + test('writes each camera into the shared value', () => { + const target: WritableSharedValue = { value: null }; + const onCameraMove = createCameraBinding(target); + const first: Camera = { + center: { latitude: 52.2, longitude: 21.0 }, + zoom: 12, + heading: 45, + }; + const second: Camera = { ...first, heading: 90 }; + + onCameraMove(first); + expect(target.value).toBe(first); + onCameraMove(second); + expect(target.value).toBe(second); + }); +}); diff --git a/package/src/reanimated/cameraBinding.ts b/package/src/reanimated/cameraBinding.ts new file mode 100644 index 0000000..82307aa --- /dev/null +++ b/package/src/reanimated/cameraBinding.ts @@ -0,0 +1,19 @@ +import type { Camera } from '../types/camera'; + +/** The part of a Reanimated shared value the binding writes to. */ +export interface WritableSharedValue { + value: Value; +} + +/** + * Returns an `onCameraMove` handler that stores every camera the map reports + * in `target`. Kept apart from the hook so it can be tested without a + * Reanimated runtime. + */ +export function createCameraBinding( + target: WritableSharedValue, +): (camera: Camera) => void { + return (camera) => { + target.value = camera; + }; +} diff --git a/package/src/reanimated/index.ts b/package/src/reanimated/index.ts new file mode 100644 index 0000000..c7598df --- /dev/null +++ b/package/src/reanimated/index.ts @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { useSharedValue, type SharedValue } from 'react-native-reanimated'; +import type { Camera } from '../types/camera'; +import { createCameraBinding } from './cameraBinding'; + +export interface CameraSharedValue { + /** The latest camera the map reported; the initial value until the first move. */ + camera: SharedValue; + + /** Pass this as `onCameraMove`. Stable for the life of the component. */ + onCameraMove: (camera: Camera) => void; +} + +/** + * Feeds `onCameraMove` updates into a Reanimated shared value, so overlays can + * follow the camera on the UI thread without a React render per update. + * + * ```tsx + * const { camera, onCameraMove } = useCameraSharedValue(); + * const compass = useAnimatedStyle(() => ({ + * transform: [{ rotate: `${-(camera.value?.heading ?? 0)}deg` }], + * })); + * + * + * + * ``` + * + * Available from `react-native-better-maps/reanimated`; `react-native-reanimated` + * is an optional peer dependency of the package. + */ +export function useCameraSharedValue( + initial: Camera | null = null, +): CameraSharedValue { + const camera = useSharedValue(initial); + return useMemo( + () => ({ camera, onCameraMove: createCameraBinding(camera) }), + [camera], + ); +} diff --git a/package/src/types/map.ts b/package/src/types/map.ts index 17c10e1..4213e47 100644 --- a/package/src/types/map.ts +++ b/package/src/types/map.ts @@ -138,6 +138,24 @@ interface BaseMapViewProps { /** Called once when a user-initiated region change ends. */ onRegionChangeComplete?: (region: Region) => void; + /** + * Called while the camera moves, at most every + * {@linkcode cameraMoveThrottleMs} (default 100 ms), and once more when it + * stops. Opt-in: the map does no per-frame work unless this is set. Meant + * for overlays that follow the camera; keep `onRegionChangeComplete` for + * loading data. Each call crosses to the JS thread, so pair a low throttle + * with cheap handlers, for example the `useCameraSharedValue` hook from + * `react-native-better-maps/reanimated`. + */ + onCameraMove?: (camera: Camera) => void; + + /** + * Minimum interval between `onCameraMove` calls, in milliseconds. `16` + * follows every frame of a 60 Hz display, `0` every frame on any display. + * Default `100`. + */ + cameraMoveThrottleMs?: number; + /** Called when the map is ready to use. */ onMapReady?: () => void;