From a394d3e0e99a959d7281fcc36105cac63d0c017c Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 8 Sep 2026 16:40:41 +0200 Subject: [PATCH 1/5] feat: opt-in camera stream and Reanimated binding Add `onCameraMove` and `cameraMoveThrottleMs` to MapView. While the camera moves the adapter emits the camera at most once per throttle interval (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 report every frame and the adapters throttle. Nothing runs unless the callback is set. Add the `react-native-better-maps/reanimated` entry point with `useCameraSharedValue`, which feeds the stream into a shared value so overlays follow the camera on the UI thread without a render per update. `react-native-reanimated` becomes an optional peer dependency. --- bun.lock | 6 +++ .../nitromaps/GoogleMapProviderAdapter.kt | 46 +++++++++++++++++ .../margelo/nitro/nitromaps/HybridMapView.kt | 18 +++++++ .../nitro/nitromaps/MapProviderAdapter.kt | 2 + package/ios/AppleMapProviderAdapter.swift | 49 +++++++++++++++++++ package/ios/GoogleMapProviderAdapter.swift | 42 ++++++++++++++++ package/ios/HybridMapView.swift | 12 +++++ package/ios/MapProviderAdapter.swift | 4 ++ package/ios/MapViewState.swift | 4 ++ package/package.json | 15 +++++- package/src/components/MapView.tsx | 5 ++ package/src/native/specs/MapView.nitro.ts | 9 ++++ .../__tests__/cameraBinding.test.ts | 24 +++++++++ package/src/reanimated/cameraBinding.ts | 19 +++++++ package/src/reanimated/index.ts | 39 +++++++++++++++ package/src/types/map.ts | 18 +++++++ 16 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 package/src/reanimated/__tests__/cameraBinding.test.ts create mode 100644 package/src/reanimated/cameraBinding.ts create mode 100644 package/src/reanimated/index.ts diff --git a/bun.lock b/bun.lock index 5a141b0..c3c0a2a 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/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 942f2d3..7bb1696 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 @@ -32,6 +33,8 @@ class GoogleMapProviderAdapter( LifecycleEventListener { 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 @@ -235,6 +238,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 @@ -426,12 +431,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 -> @@ -716,6 +724,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) @@ -760,6 +802,8 @@ class GoogleMapProviderAdapter( // view that is already gone. onRegionChange = null onRegionChangeComplete = null + onCameraMove = null + cameraMoveThrottleMs = null onMapReady = null onPress = null onPoiPress = null @@ -795,3 +839,5 @@ class GoogleMapProviderAdapter( } private fun normalizeGoogleMapId(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } + +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 9cf1cba..0a77e75 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( 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 @@ -343,6 +357,8 @@ class HybridMapView( pinStyle = null onRegionChange = null onRegionChangeComplete = null + onCameraMove = null + _cameraMoveThrottleMs = null onMapReady = null onPress = null onPoiPress = null @@ -423,6 +439,8 @@ class HybridMapView( 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 376d697..6c80e63 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 66bbcec..7b4fd17 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? @@ -188,6 +190,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() @@ -281,6 +285,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 @@ -467,6 +475,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)) @@ -572,6 +611,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { extension GoogleMapProviderAdapter: GMSMapViewDelegate { func mapView(_ mapView: GMSMapView, willMove gesture: Bool) { handleRegionWillChange(userInteracting: gesture) + startCameraStream() if gesture { startGestureMarkerRefresh() } @@ -579,12 +619,14 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate { func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { refreshGestureMarkersIfNeeded() + emitCameraMoveIfDue(position) } func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) { flushPendingRegionFitIfPossible() refreshVisibleMarkers() stopGestureMarkerRefresh() + stopCameraStream(at: position) handleRegionDidChange() notifyMapReadyIfNeeded() } diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift index daca2c2..acc5325 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 3a168b6..a24233a 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" }, @@ -84,7 +89,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", @@ -96,6 +102,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" }, @@ -116,5 +124,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; From 1dc25261b8117e9f79983b6b64f88c145454e26e Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 8 Sep 2026 16:43:12 +0200 Subject: [PATCH 2/5] feat(example): camera compass, benchmark scenarios O and P, ADR 0007 The example app grows a compass that follows the map heading through `useCameraSharedValue`. The benchmark harness adds O (pan with the camera stream feeding a shared value every frame) and P (100,000 clustered markers), and routes free-form notes through the native log line so they survive release builds. ADR 0007 records the camera stream, the Reanimated binding and the decision not to build the shared C++ core, with the signpost data behind it. The benchmark results for both scenarios on the simulator and the emulator go into docs/benchmarks.md; README, architecture and changelog cover the API. --- README.md | 36 ++++++ docs/adr/0007-camera-stream-and-cpp-core.md | 62 ++++++++++ docs/architecture.md | 3 + docs/benchmarks.md | 126 +++++++++++++++++--- example/App.tsx | 64 ++++++++++ example/benchmark/BenchmarkApp.tsx | 2 + example/benchmark/scenarios.ts | 57 +++++++++ example/maestro/benchmark-run-all.yaml | 4 +- 8 files changed, 336 insertions(+), 18 deletions(-) create mode 100644 docs/adr/0007-camera-stream-and-cpp-core.md diff --git a/README.md b/README.md index fa6d45c..26a96da 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,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: @@ -626,6 +660,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 | @@ -657,6 +692,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/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 f002c54..4627da5 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 @@ Marker datasets live in a native `MarkerStore` behind the `MarkerCollection` Hyb 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 events at the end of a move, 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 dfa901d..650cc45 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. @@ -355,6 +357,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. */ @@ -321,6 +340,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 From 27b5b9caf50e73a6f88c80614386205228734e86 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Tue, 8 Sep 2026 18:01:11 +0200 Subject: [PATCH 3/5] docs: state when each region event fires --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4627da5..110b43d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,7 @@ Marker datasets live in a native `MarkerStore` behind the `MarkerCollection` Hyb 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 events at the end of a move, 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). +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. From b55a1110f9f73d97395383cd4ec625a7aa0f1c64 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Wed, 16 Sep 2026 14:41:46 +0200 Subject: [PATCH 4/5] fix: normalize cameraMoveThrottleMs and clarify camera README examples Invalid throttle values fall back to the 100 ms default at MapView and in the native adapters. README now steers data loading to onRegionChangeComplete and ships a self-contained Reanimated compass snippet. --- README.md | 20 +++++++++++++++--- .../nitromaps/GoogleMapProviderAdapter.kt | 12 ++++++++++- package/ios/AppleMapProviderAdapter.swift | 12 ++++++++++- package/ios/GoogleMapProviderAdapter.swift | 12 ++++++++++- package/src/components/MapView.tsx | 3 ++- .../__tests__/cameraMoveThrottle.test.ts | 21 +++++++++++++++++++ package/src/utils/cameraMoveThrottle.ts | 13 ++++++++++++ 7 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 package/src/utils/__tests__/cameraMoveThrottle.test.ts create mode 100644 package/src/utils/cameraMoveThrottle.ts diff --git a/README.md b/README.md index 26a96da..0c2026b 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,9 @@ 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: +`onRegionChange` fires when a gesture begins and can report a transient region. +Use `onRegionChangeComplete` for data loading when the gesture ends. An overlay +that must track the camera while it moves opts into a throttled stream: ```tsx + - + ); } + +const styles = StyleSheet.create({ + needle: { + position: 'absolute', + top: 48, + alignSelf: 'center', + color: '#FF453A', + fontSize: 18, + }, +}); ``` `react-native-reanimated` is an optional peer dependency; the main entry point does not import it. 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 7bb1696..eb83d66 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 @@ -742,7 +742,7 @@ class GoogleMapProviderAdapter( return } val now = SystemClock.uptimeMillis() - val interval = (cameraMoveThrottleMs ?: DEFAULT_CAMERA_MOVE_THROTTLE_MS).coerceAtLeast(0.0).toLong() + val interval = resolvedCameraMoveThrottleMs().toLong() if (lastCameraEmitMs != 0L && now - lastCameraEmitMs < interval) { return } @@ -758,6 +758,16 @@ class GoogleMapProviderAdapter( onCameraMove?.invoke(map.cameraPosition.toCamera()) } + /** Finite intervals ≥ 0, otherwise the documented 100 ms default. */ + private fun resolvedCameraMoveThrottleMs(): Double { + val value = cameraMoveThrottleMs + return if (value != null && value.isFinite() && value >= 0.0) { + value + } else { + DEFAULT_CAMERA_MOVE_THROTTLE_MS + } + } + private fun handleRegionDidChange() { if (isUserGesture) { emitRegionChange(complete = true) diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift index 0ada0cd..6e00a83 100644 --- a/package/ios/AppleMapProviderAdapter.swift +++ b/package/ios/AppleMapProviderAdapter.swift @@ -370,7 +370,7 @@ final class AppleMapProviderAdapter: MapProviderAdapter { cameraStreamClock.stop() return } - let interval = max(0, (cameraMoveThrottleMs ?? 100) / 1000) + let interval = resolvedCameraMoveThrottleSeconds() guard frame.timestamp - lastCameraEmitTime >= interval else { return } @@ -378,6 +378,14 @@ final class AppleMapProviderAdapter: MapProviderAdapter { onCameraMove(view.camera.toCamera()) } + /// Finite intervals ≥ 0, otherwise the documented 100 ms default. + private func resolvedCameraMoveThrottleSeconds() -> CFTimeInterval { + guard let value = cameraMoveThrottleMs, value.isFinite, value >= 0 else { + return Self.defaultCameraMoveThrottleMs / 1000 + } + return value / 1000 + } + func startLiveClustering() { overlayController.beginLiveRefresh() } @@ -543,4 +551,6 @@ final class AppleMapProviderAdapter: MapProviderAdapter { } } + private static let defaultCameraMoveThrottleMs: Double = 100 + } diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift index 7b4fd17..2e6d264 100644 --- a/package/ios/GoogleMapProviderAdapter.swift +++ b/package/ios/GoogleMapProviderAdapter.swift @@ -490,7 +490,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { return } let now = CACurrentMediaTime() - let interval = max(0, (cameraMoveThrottleMs ?? 100) / 1000) + let interval = resolvedCameraMoveThrottleSeconds() guard now - lastCameraEmitTime >= interval else { return } @@ -498,6 +498,14 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { onCameraMove(position.toCamera()) } + /// Finite intervals ≥ 0, otherwise the documented 100 ms default. + private func resolvedCameraMoveThrottleSeconds() -> CFTimeInterval { + guard let value = cameraMoveThrottleMs, value.isFinite, value >= 0 else { + return Self.defaultCameraMoveThrottleMs / 1000 + } + return value / 1000 + } + private func stopCameraStream(at position: GMSCameraPosition) { guard isCameraStreaming else { return @@ -606,6 +614,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter { mapView.mapStyle = try? GMSMapStyle(jsonString: customMapStyle) } + + private static let defaultCameraMoveThrottleMs: Double = 100 } extension GoogleMapProviderAdapter: GMSMapViewDelegate { diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx index 89972b2..7a76347 100644 --- a/package/src/components/MapView.tsx +++ b/package/src/components/MapView.tsx @@ -33,6 +33,7 @@ import { resolveMapProvider } from '../providers'; import type { Coordinate } from '../types/coordinate'; import type { MapViewProps, PoiPressEvent } from '../types/map'; import type { MapViewRef } from '../types/ref'; +import { normalizeCameraMoveThrottleMs } from '../utils/cameraMoveThrottle'; import { normalizeEnteringAnimation } from '../utils/enteringAnimation'; import { camerasEqual, @@ -386,7 +387,7 @@ export function MapView({ onRegionChange={onRegionChangeCallback} onRegionChangeComplete={onRegionChangeCompleteCallback} onCameraMove={onCameraMoveCallback} - cameraMoveThrottleMs={cameraMoveThrottleMs} + cameraMoveThrottleMs={normalizeCameraMoveThrottleMs(cameraMoveThrottleMs)} onMapReady={onMapReadyCallback} onPress={onPressCallback} onPoiPress={onPoiPressNativeCallback} diff --git a/package/src/utils/__tests__/cameraMoveThrottle.test.ts b/package/src/utils/__tests__/cameraMoveThrottle.test.ts new file mode 100644 index 0000000..24feec8 --- /dev/null +++ b/package/src/utils/__tests__/cameraMoveThrottle.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeCameraMoveThrottleMs } from '../cameraMoveThrottle'; + +describe('normalizeCameraMoveThrottleMs', () => { + test('passes undefined through', () => { + expect(normalizeCameraMoveThrottleMs(undefined)).toBeUndefined(); + }); + + test('keeps finite values at or above zero', () => { + expect(normalizeCameraMoveThrottleMs(0)).toBe(0); + expect(normalizeCameraMoveThrottleMs(16)).toBe(16); + expect(normalizeCameraMoveThrottleMs(100)).toBe(100); + }); + + test('maps negative, NaN, and non-finite values to undefined', () => { + expect(normalizeCameraMoveThrottleMs(-1)).toBeUndefined(); + expect(normalizeCameraMoveThrottleMs(Number.NaN)).toBeUndefined(); + expect(normalizeCameraMoveThrottleMs(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(normalizeCameraMoveThrottleMs(Number.NEGATIVE_INFINITY)).toBeUndefined(); + }); +}); diff --git a/package/src/utils/cameraMoveThrottle.ts b/package/src/utils/cameraMoveThrottle.ts new file mode 100644 index 0000000..877628a --- /dev/null +++ b/package/src/utils/cameraMoveThrottle.ts @@ -0,0 +1,13 @@ +/** + * Keeps only finite throttle intervals ≥ 0. Negative, NaN, and non-finite + * values become `undefined` so native adapters apply the documented 100 ms + * default. + */ +export function normalizeCameraMoveThrottleMs( + value: number | undefined, +): number | undefined { + if (value == null || !Number.isFinite(value) || value < 0) { + return undefined; + } + return value; +} From 5a3289829cee46f57e4c6f8e3c3f9da6d6a83547 Mon Sep 17 00:00:00 2001 From: Jakub Kasprzyk Date: Wed, 16 Sep 2026 14:49:23 +0200 Subject: [PATCH 5/5] fix: address remaining CodeRabbit follow-ups Clarify that data loading belongs on onRegionChangeComplete, serialize manual benchmark recording transitions, drop stale cluster lookups when the demo remounts the map, and re-fit the region when Android map padding changes under a region-driven camera. --- docs/architecture.md | 12 ++- example/App.tsx | 3 + example/benchmark/BenchmarkApp.tsx | 101 ++++++++++-------- .../nitromaps/GoogleMapProviderAdapter.kt | 8 +- 4 files changed, 76 insertions(+), 48 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 110b43d..6516f8c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,17 @@ Marker datasets live in a native `MarkerStore` behind the `MarkerCollection` Hyb 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). +The camera reaches JS through two events per gesture: `onRegionChange` once when +a user gesture begins, and `onRegionChangeComplete` when it ends. Use +`onRegionChangeComplete` for data loading; `onRegionChange` can report a +transient region at the start of the move. 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. diff --git a/example/App.tsx b/example/App.tsx index 785f741..60b9d3d 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -765,6 +765,7 @@ export default function App() { return current; } + latestClusterRequest.current += 1; const next = (current + 1) % SUPPORTED_MAP_PROVIDERS.length; setMapReady(false); setStatus(PROVIDER_LABELS[SUPPORTED_MAP_PROVIDERS[next] ?? provider]); @@ -777,6 +778,7 @@ export default function App() { if (index === scenarioIndex) { return; } + latestClusterRequest.current += 1; setScenarioIndex(index); setMapReady(false); setStatus(MAP_SCENARIOS[index].name); @@ -797,6 +799,7 @@ export default function App() { return; } + latestClusterRequest.current += 1; setAnimationOptionIndex(nextIndex); setMapReady(false); setStatus(`Animation · ${ANIMATION_OPTIONS[nextIndex].label}`); diff --git a/example/benchmark/BenchmarkApp.tsx b/example/benchmark/BenchmarkApp.tsx index f6d0799..5314235 100644 --- a/example/benchmark/BenchmarkApp.tsx +++ b/example/benchmark/BenchmarkApp.tsx @@ -80,6 +80,7 @@ export default function BenchmarkApp() { lag: LagSampler; beforeBytes: number; } | null>(null); + const manualTransition = useRef(false); const scenario = SCENARIOS[scenarioIndex]; @@ -173,57 +174,67 @@ export default function BenchmarkApp() { }, [appendResult, context, mount, provider, running, scenario]); const toggleManualRecording = useCallback(async () => { + if (manualTransition.current) { + return; + } + manualTransition.current = true; const active = manualRecording.current; - if (active == null) { - const beforeBytes = await memoryFootprintBytes(); - const lag = startJsLagSampler(); + try { + if (active == null) { + const beforeBytes = await memoryFootprintBytes(); + const lag = startJsLagSampler(); + try { + await startFrameRecording(); + } catch (error) { + lag.stop(); + manualRecording.current = null; + setManualActive(false); + setStatus(`Failed: ${String(error)}`); + return; + } + manualRecording.current = { lag, beforeBytes }; + setManualActive(true); + setStatus('Recording: gesture now, then tap Stop'); + return; + } + try { - await startFrameRecording(); + const recording = await stopFrameRecording(); + const afterBytes = await memoryFootprintBytes(); + const rawRate = + recording.refreshRateHz || (await displayRefreshRateHz()); + const refreshRateHz = + Number.isFinite(rawRate) && rawRate > 0 ? rawRate : 60; + const frames = computeFrameStats({ ...recording, refreshRateHz }); + const jsLag = active.lag.stop(); + const MB = 1024 * 1024; + const result: ScenarioResult = { + id: `manual-${scenario.id}`, + name: `Manual · ${scenario.name}`, + platform: Platform.OS, + provider, + recordedAt: new Date().toISOString(), + frames, + jsLag, + memory: { + beforeMB: active.beforeBytes / MB, + afterMB: afterBytes / MB, + deltaMB: (afterBytes - active.beforeBytes) / MB, + }, + evaluation: evaluateFrameStats(frames, jsLag), + }; + await publishResult(result); + appendResult(result); + setStatus('Done'); } catch (error) { - lag.stop(); setStatus(`Failed: ${String(error)}`); - return; + } finally { + active.lag.stop(); + manualRecording.current = null; + setManualActive(false); } - manualRecording.current = { lag, beforeBytes }; - setManualActive(true); - setStatus('Recording: gesture now, then tap Stop'); - return; - } - - try { - const recording = await stopFrameRecording(); - const afterBytes = await memoryFootprintBytes(); - const rawRate = - recording.refreshRateHz || (await displayRefreshRateHz()); - const refreshRateHz = - Number.isFinite(rawRate) && rawRate > 0 ? rawRate : 60; - const frames = computeFrameStats({ ...recording, refreshRateHz }); - const jsLag = active.lag.stop(); - const MB = 1024 * 1024; - const result: ScenarioResult = { - id: `manual-${scenario.id}`, - name: `Manual · ${scenario.name}`, - platform: Platform.OS, - provider, - recordedAt: new Date().toISOString(), - frames, - jsLag, - memory: { - beforeMB: active.beforeBytes / MB, - afterMB: afterBytes / MB, - deltaMB: (afterBytes - active.beforeBytes) / MB, - }, - evaluation: evaluateFrameStats(frames, jsLag), - }; - await publishResult(result); - appendResult(result); - setStatus('Done'); - } catch (error) { - setStatus(`Failed: ${String(error)}`); } finally { - active.lag.stop(); - manualRecording.current = null; - setManualActive(false); + manualTransition.current = false; } }, [appendResult, provider, scenario]); 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 eb83d66..ac17563 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 @@ -211,13 +211,17 @@ class GoogleMapProviderAdapter( get() = _mapPadding set(value) { // Padding changes the camera that a region fit produces, so drop the - // skip-cache and let the next same-region apply recompute. - if (value != _mapPadding) { + // skip-cache and re-fit the current region when the camera is region-driven. + val paddingChanged = value != _mapPadding + if (paddingChanged) { lastAppliedRegion = null lastAppliedRegionCamera = null } _mapPadding = value applyMapPadding() + if (paddingChanged && _camera == null) { + _region?.let(::applyRegion) + } } private var _markerEnteringAnimation: OverlayEnteringAnimationDescriptor? = null