From cfc53a5288f464406f480f44bd2c4598a1ac1d96 Mon Sep 17 00:00:00 2001 From: Eric Kreutzer Date: Mon, 9 Nov 2026 14:39:07 -0700 Subject: [PATCH 1/4] feat: move markers along timed paths natively Marker takes a `path` (keyframe coordinates, times, optional rotations, a wall-clock start time, loop) and native code moves it: Core Animation keyframes on Google Maps for iOS, a frame timer on Apple Maps and Android, and requestAnimationFrame on web. Each segment turns the short way. `pathPositionAt` gives the same position in JS. MapView takes `idleFrameRate`: on Google Maps for iOS the map renders at 60, 30 or 15 fps while the camera is still and at full rate while it moves; on Apple Maps and Android it sets how often path markers move. --- .../src/main/java/com/luggmaps/LuggMapView.kt | 8 + .../java/com/luggmaps/LuggMapViewManager.kt | 5 + .../main/java/com/luggmaps/LuggMarkerView.kt | 87 ++++- .../com/luggmaps/LuggMarkerViewManager.kt | 39 +++ .../com/luggmaps/core/GoogleMapProvider.kt | 98 +++++- .../com/luggmaps/core/MapProviderDelegate.kt | 1 + docs/content/docs/components/map-view.mdx | 13 + docs/content/docs/components/marker.mdx | 51 +++ ios/LuggMapView.mm | 7 + ios/LuggMarkerView.h | 28 ++ ios/LuggMarkerView.mm | 126 +++++++ ios/core/AppleMapProvider.mm | 83 ++++- ios/core/GoogleMapProvider.mm | 324 +++++++++++++++++- ios/core/MapProviderDelegate.h | 2 + ios/core/MarkerPathTicker.h | 18 + ios/core/MarkerPathTicker.mm | 78 +++++ ios/core/StaticMapProviderBase.mm | 4 + skills/maps-usage/SKILL.md | 18 + skills/maps-usage/references/configuration.md | 2 + src/MapView.tsx | 2 + src/MapView.types.ts | 12 + src/__tests__/markerPath.test.ts | 60 ++++ src/components/Marker.tsx | 7 + src/components/Marker.types.ts | 38 ++ src/components/Marker.web.tsx | 107 +++++- src/components/index.ts | 3 + src/components/index.web.ts | 4 +- src/components/markerPath.ts | 73 ++++ src/fabric/LuggMapViewNativeComponent.ts | 4 + src/fabric/LuggMarkerViewNativeComponent.ts | 5 + src/index.ts | 5 +- src/index.web.ts | 9 +- 32 files changed, 1292 insertions(+), 29 deletions(-) create mode 100644 ios/core/MarkerPathTicker.h create mode 100644 ios/core/MarkerPathTicker.mm create mode 100644 src/__tests__/markerPath.test.ts create mode 100644 src/components/markerPath.ts diff --git a/android/src/main/java/com/luggmaps/LuggMapView.kt b/android/src/main/java/com/luggmaps/LuggMapView.kt index 98a735f..5cce2f2 100644 --- a/android/src/main/java/com/luggmaps/LuggMapView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapView.kt @@ -75,6 +75,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : private var maxZoom: Double? = null private var edgeInsets: EdgeInsets = EdgeInsets() private var insetAdjustment: String = "never" + private var idleFrameRate: String = "maximum" // region View Lifecycle @@ -214,6 +215,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : provider?.setPoiEnabled(poiEnabled) provider?.setPoiFilterMode(poiFilterMode) provider?.setPoiFilterCategories(poiFilterCategories) + provider?.setIdleFrameRate(idleFrameRate) } fun setProvider(value: String?) { @@ -330,6 +332,12 @@ class LuggMapView(private val reactContext: ThemedReactContext) : provider?.setInsetAdjustment(value) } + fun setIdleFrameRate(value: String) { + if (idleFrameRate == value) return + idleFrameRate = value + provider?.setIdleFrameRate(value) + } + fun setEdgeInsets( top: Int, left: Int, diff --git a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt index c82c91c..fa3009c 100644 --- a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +++ b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt @@ -202,6 +202,11 @@ class LuggMapViewManager : view.setInsetAdjustment(value ?: "never") } + @ReactProp(name = "idleFrameRate") + override fun setIdleFrameRate(view: LuggMapView, value: String?) { + view.setIdleFrameRate(value ?: "maximum") + } + @ReactProp(name = "edgeInsets") override fun setEdgeInsets(view: LuggMapView, value: ReadableMap?) { value?.let { diff --git a/android/src/main/java/com/luggmaps/LuggMarkerView.kt b/android/src/main/java/com/luggmaps/LuggMarkerView.kt index fde9141..50da609 100644 --- a/android/src/main/java/com/luggmaps/LuggMarkerView.kt +++ b/android/src/main/java/com/luggmaps/LuggMarkerView.kt @@ -68,6 +68,32 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { var isDragging: Boolean = false + private var pathLatitudes = DoubleArray(0) + private var pathLongitudes = DoubleArray(0) + private var pathTimes = DoubleArray(0) + private var pathRotations = DoubleArray(0) + private var pathStartTime = 0.0 + private var pathLoop = false + + // The path position at the last updatePathPosition + var pathLatitude: Double = 0.0 + private set + var pathLongitude: Double = 0.0 + private set + var pathRotation: Float = 0f + private set + + val hasPath: Boolean + get() = pathTimes.size >= 2 && pathTimes.size == pathLatitudes.size + + // A path overrides coordinate, and rotate when the path has rotations + val displayLatitude: Double + get() = if (hasPath) pathLatitude else latitude + val displayLongitude: Double + get() = if (hasPath) pathLongitude else longitude + val displayRotation: Float + get() = if (hasPath) pathRotation else rotate + var didLayout: Boolean = false private set @@ -290,8 +316,67 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { this.draggable = draggable } + fun setPathCoordinates(latitudes: DoubleArray, longitudes: DoubleArray) { + pathLatitudes = latitudes + pathLongitudes = longitudes + } + + fun setPathTimes(times: DoubleArray) { + pathTimes = times + } + + // Unwrapped so each segment turns the short way (350 then 10 is +20, not -340) + fun setPathRotations(rotations: DoubleArray) { + pathRotations = rotations.copyOf() + for (i in 1 until pathRotations.size) { + val previous = pathRotations[i - 1] + pathRotations[i] = previous + ((((rotations[i] - previous) % 360) + 540) % 360) - 180 + } + } + + fun setPathStartTime(startTime: Double) { + pathStartTime = startTime + } + + fun setPathLoop(loop: Boolean) { + pathLoop = loop + } + + // `now` is wall-clock epoch ms, the same clock as pathStartTime + fun updatePathPosition(now: Double) { + if (!hasPath) return + val times = pathTimes + val last = times.size - 1 + val duration = times[last] - times[0] + val elapsed = now - pathStartTime + val phase = if (pathLoop && duration > 0) { + ((elapsed % duration) + duration) % duration + } else { + // Not coerceIn: it throws when unsorted times make the duration negative + maxOf(0.0, minOf(elapsed, duration)) + } + val time = times[0] + phase + + var index = 0 + var high = last - 1 + while (index < high) { + val mid = (index + high + 1) ushr 1 + if (times[mid] <= time) index = mid else high = mid - 1 + } + val span = times[index + 1] - times[index] + val fraction = if (span > 0) (time - times[index]) / span else 0.0 + + pathLatitude = pathLatitudes[index] + (pathLatitudes[index + 1] - pathLatitudes[index]) * fraction + pathLongitude = pathLongitudes[index] + (pathLongitudes[index + 1] - pathLongitudes[index]) * fraction + pathRotation = if (pathRotations.size == times.size) { + (pathRotations[index] + (pathRotations[index + 1] - pathRotations[index]) * fraction).toFloat() + } else { + rotate + } + } + fun emitPressEvent(x: Float, y: Float) { - dispatchEvent(MarkerPressEvent(this, latitude, longitude, x, y)) + dispatchEvent(MarkerPressEvent(this, displayLatitude, displayLongitude, x, y)) } fun emitDragStartEvent(x: Float, y: Float) { diff --git a/android/src/main/java/com/luggmaps/LuggMarkerViewManager.kt b/android/src/main/java/com/luggmaps/LuggMarkerViewManager.kt index 9b69662..5fc9d0e 100644 --- a/android/src/main/java/com/luggmaps/LuggMarkerViewManager.kt +++ b/android/src/main/java/com/luggmaps/LuggMarkerViewManager.kt @@ -1,6 +1,7 @@ package com.luggmaps import android.util.Log +import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.ThemedReactContext @@ -102,6 +103,44 @@ class LuggMarkerViewManager : view.setDraggable(value) } + @ReactProp(name = "pathCoordinates") + override fun setPathCoordinates(view: LuggMarkerView, value: ReadableArray?) { + val size = value?.size() ?: 0 + val latitudes = DoubleArray(size) + val longitudes = DoubleArray(size) + for (i in 0 until size) { + val coordinate = value?.getMap(i) + latitudes[i] = coordinate?.getDouble("latitude") ?: 0.0 + longitudes[i] = coordinate?.getDouble("longitude") ?: 0.0 + } + view.setPathCoordinates(latitudes, longitudes) + } + + @ReactProp(name = "pathTimes") + override fun setPathTimes(view: LuggMarkerView, value: ReadableArray?) { + view.setPathTimes(value.toDoubleArray()) + } + + @ReactProp(name = "pathRotations") + override fun setPathRotations(view: LuggMarkerView, value: ReadableArray?) { + view.setPathRotations(value.toDoubleArray()) + } + + @ReactProp(name = "pathStartTime") + override fun setPathStartTime(view: LuggMarkerView, value: Double) { + view.setPathStartTime(value) + } + + @ReactProp(name = "pathLoop", defaultBoolean = false) + override fun setPathLoop(view: LuggMarkerView, value: Boolean) { + view.setPathLoop(value) + } + + private fun ReadableArray?.toDoubleArray(): DoubleArray { + val array = this ?: return DoubleArray(0) + return DoubleArray(array.size()) { array.getDouble(it) } + } + override fun showCallout(view: LuggMarkerView) { view.showCallout() } diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 70e0bfb..6703c26 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.content.res.Configuration import android.os.SystemClock +import android.view.Choreographer import android.view.View import android.view.ViewTreeObserver import android.widget.ImageView @@ -76,7 +77,8 @@ class GoogleMapProvider(private val context: Context) : GoogleMap.OnGroundOverlayClickListener, GoogleMap.OnMarkerClickListener, GoogleMap.OnMarkerDragListener, - GoogleMap.InfoWindowAdapter { + GoogleMap.InfoWindowAdapter, + Choreographer.FrameCallback { override var delegate: MapProviderDelegate? = null override val isMapReady: Boolean get() = _isMapReady @@ -105,6 +107,12 @@ class GoogleMapProvider(private val context: Context) : private val groundOverlayToViewMap = mutableMapOf() private val markerToViewMap = mutableMapOf() private val liveMarkerViews = mutableSetOf() + private val pathMarkerViews = mutableSetOf() + private val choreographer = Choreographer.getInstance() + private var isPathTickerRunning = false + private var animationsPaused = false + private var pathFrameIntervalNanos = 0L + private var lastPathFrameNanos = 0L private var staticProjectionReady = false private var isLiteMode = false private var activeNonBubbledMarker: Marker? = null @@ -204,6 +212,8 @@ class GoogleMapProvider(private val context: Context) : markerView.onUpdate = null } liveMarkerViews.clear() + pathMarkerViews.clear() + updatePathTicker() pendingMarkerViews.clear() pendingPolylineViews.clear() pendingPolygonViews.clear() @@ -644,6 +654,15 @@ class GoogleMapProvider(private val context: Context) : override fun setPoiFilterCategories(categories: List) {} + // Android has no map frame rate setting, so this only paces path markers + override fun setIdleFrameRate(value: String) { + pathFrameIntervalNanos = when (value) { + "conservative" -> CONSERVATIVE_FRAME_INTERVAL_NANOS + "powerSave" -> POWER_SAVE_FRAME_INTERVAL_NANOS + else -> 0L + } + } + // endregion // region MarkerViewDelegate @@ -747,6 +766,7 @@ class GoogleMapProvider(private val context: Context) : markerView.marker?.let { markerToViewMap.remove(it) } markerView.marker?.remove() markerView.marker = null + updatePathMarker(markerView) } private fun syncMarkerView(markerView: LuggMarkerView) { @@ -761,17 +781,19 @@ class GoogleMapProvider(private val context: Context) : return } + markerView.updatePathPosition(System.currentTimeMillis().toDouble()) markerView.marker?.apply { if (!markerView.isDragging) { - position = LatLng(markerView.latitude, markerView.longitude) + position = LatLng(markerView.displayLatitude, markerView.displayLongitude) } title = markerView.title snippet = markerView.description setAnchor(markerView.anchorX, markerView.anchorY) zIndex = markerView.zIndex - rotation = markerView.rotate + rotation = markerView.displayRotation isDraggable = markerView.draggable } + updatePathMarker(markerView) if (markerView.hasCustomView) { if (markerView.scaleChanged) { @@ -797,7 +819,8 @@ class GoogleMapProvider(private val context: Context) : private fun addMarkerViewToMap(markerView: LuggMarkerView) { val map = googleMap ?: return - val position = LatLng(markerView.latitude, markerView.longitude) + markerView.updatePathPosition(System.currentTimeMillis().toDouble()) + val position = LatLng(markerView.displayLatitude, markerView.displayLongitude) val options = MarkerOptions() .position(position) .title(markerView.title) @@ -806,11 +829,12 @@ class GoogleMapProvider(private val context: Context) : val marker = map.addMarker(options) ?: return marker.setAnchor(markerView.anchorX, markerView.anchorY) marker.zIndex = markerView.zIndex - marker.rotation = markerView.rotate + marker.rotation = markerView.displayRotation marker.isDraggable = markerView.draggable markerView.marker = marker markerToViewMap[marker] = markerView + updatePathMarker(markerView) if (markerView.hasCustomView) { if (markerView.rasterize) { @@ -870,7 +894,7 @@ class GoogleMapProvider(private val context: Context) : val map = googleMap ?: return if (staticMode && !staticProjectionReady) return val contentView = markerView.contentView - val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude)) + val point = map.projection.toScreenLocation(LatLng(markerView.displayLatitude, markerView.displayLongitude)) contentView.translationX = point.x - contentView.width * markerView.anchorX contentView.translationY = point.y - contentView.height * markerView.anchorY contentView.visibility = View.VISIBLE @@ -878,6 +902,60 @@ class GoogleMapProvider(private val context: Context) : // endregion + // region Marker Paths + + private fun updatePathMarker(markerView: LuggMarkerView) { + if (markerView.hasPath && markerView.marker != null) { + pathMarkerViews.add(markerView) + } else { + pathMarkerViews.remove(markerView) + } + updatePathTicker() + } + + // Static maps render once, so their path markers stay put like animated polylines do + private fun updatePathTicker() { + val shouldRun = pathMarkerViews.isNotEmpty() && !animationsPaused && !staticMode + if (shouldRun == isPathTickerRunning) return + isPathTickerRunning = shouldRun + if (shouldRun) { + lastPathFrameNanos = 0L + choreographer.postFrameCallback(this) + } else { + choreographer.removeFrameCallback(this) + } + } + + // One callback moves every path marker, so the map redraws once per tick + override fun doFrame(frameTimeNanos: Long) { + choreographer.postFrameCallback(this) + if (frameTimeNanos - lastPathFrameNanos < pathFrameIntervalNanos) return + lastPathFrameNanos = frameTimeNanos + + val now = System.currentTimeMillis().toDouble() + var moved = false + for (markerView in pathMarkerViews) { + val marker = markerView.marker ?: continue + if (markerView.isDragging) continue + val latitude = markerView.pathLatitude + val longitude = markerView.pathLongitude + val rotation = markerView.pathRotation + markerView.updatePathPosition(now) + // Parked markers skip the setters, so a parked fleet doesn't redraw the map + if (markerView.pathLatitude != latitude || markerView.pathLongitude != longitude) { + marker.position = LatLng(markerView.pathLatitude, markerView.pathLongitude) + if (markerView.hasCustomView && !markerView.rasterize) positionLiveMarker(markerView) + moved = true + } + if (markerView.pathRotation != rotation) { + marker.rotation = markerView.pathRotation + } + } + if (moved) positionNonBubbledCallout() + } + + // endregion + // region Polyline Management override fun addPolylineView(polylineView: LuggPolylineView) { @@ -1250,10 +1328,14 @@ class GoogleMapProvider(private val context: Context) : override fun pauseAnimations() { polylineAnimators.values.forEach { it.pause() } + animationsPaused = true + updatePathTicker() } override fun resumeAnimations() { polylineAnimators.values.forEach { it.resume() } + animationsPaused = false + updatePathTicker() } // endregion @@ -1653,5 +1735,9 @@ class GoogleMapProvider(private val context: Context) : "light" -> false else -> (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES } + + // Just under 1/30 s and 1/15 s, so vsync jitter can't push a tick to the next frame + private const val CONSERVATIVE_FRAME_INTERVAL_NANOS = 32_000_000L + private const val POWER_SAVE_FRAME_INTERVAL_NANOS = 64_000_000L } } diff --git a/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt b/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt index 0b8701c..00c6b31 100644 --- a/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt +++ b/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt @@ -43,6 +43,7 @@ interface MapProvider { fun setPoiEnabled(enabled: Boolean) fun setPoiFilterMode(mode: String) fun setPoiFilterCategories(categories: List) + fun setIdleFrameRate(value: String) // Children fun addMarkerView(markerView: LuggMarkerView) diff --git a/docs/content/docs/components/map-view.mdx b/docs/content/docs/components/map-view.mdx index 2dd746b..8f7c1cc 100644 --- a/docs/content/docs/components/map-view.mdx +++ b/docs/content/docs/components/map-view.mdx @@ -43,6 +43,7 @@ import { MapView } from '@lugg/maps'; | `poiFilter` | [`PoiFilter`](#poifilter) | - | Filter POI categories (Apple Maps only) | | `theme` | [`MapTheme`](#maptheme) | `'system'` | Map color theme | | `insetAdjustment` | `'automatic' \| 'never'` | `'never'` | Safe area inset adjustment behavior | +| `idleFrameRate` | [`MapIdleFrameRate`](#mapidleframerate) | `'maximum'` | How often the map redraws for moving `path` markers while the camera is still | | `onPress` | `(event: MapPressEvent) => void` | - | Called when the map is pressed | | `onLongPress` | `(event: MapPressEvent) => void` | - | Called when the map is long pressed | | `onCameraMove` | `(event: MapCameraEvent) => void` | - | Called when camera moves | @@ -168,6 +169,18 @@ Notes: | `'never'` | No safe area inset adjustment | | `'automatic'` | Automatically adjust for safe area insets | +### MapIdleFrameRate + +How often the map redraws while the camera is still, for markers that move along a [`path`](./marker.mdx#moving-along-a-path). The map redraws at full rate while the camera moves. + +| Value | Google Maps (iOS) | Android and Apple Maps | Web | +|-------|-------------------|------------------------|-----| +| `'maximum'` | Full frame rate | Path markers move every frame | Ignored | +| `'conservative'` | 30 fps | Path markers move 30 times a second | Ignored | +| `'powerSave'` | 15 fps | Path markers move 15 times a second | Ignored | + +On Google Maps for iOS this is the whole map's frame rate at rest, so it also slows other animations, such as an animated `Polyline`, while the camera is still. + ### PoiFilter ```ts diff --git a/docs/content/docs/components/marker.mdx b/docs/content/docs/components/marker.mdx index 31c160c..c5389c7 100644 --- a/docs/content/docs/components/marker.mdx +++ b/docs/content/docs/components/marker.mdx @@ -42,6 +42,7 @@ import { MapView, Marker } from '@lugg/maps'; | `rasterize` | `boolean` | `true` | Rasterize custom marker view to bitmap (iOS/Android). On web, `false` positions the custom view between pixels so markers moved every frame glide. Markers with `title`, `callout` or `draggable` keep whole-pixel positioning | | `centerOnPress` | `boolean` | `true` | Whether the map centers on the marker when pressed | | `draggable` | `boolean` | `false` | Whether the marker can be dragged by the user | +| `path` | [`MarkerPath`](#markerpath) | - | Moves the marker along a timed path natively. While set, `coordinate` is ignored, and so is `rotate` when the path has `rotations` | | `onPress` | `(event: MarkerPressEvent) => void` | - | Called when the marker is pressed. Event includes `coordinate` and `point` | | `onDragStart` | `(event: MarkerDragEvent) => void` | - | Called when marker drag starts. Event includes `coordinate` and `point` | | `onDragChange` | `(event: MarkerDragEvent) => void` | - | Called continuously as the marker is dragged. Event includes `coordinate` and `point` | @@ -92,6 +93,56 @@ Set `draggable` to enable marker dragging. Use the drag event callbacks to track /> ``` +## Moving Along a Path + +Give a marker a timed `path` and native code moves it, so React doesn't render or send props while it moves. Google Maps on iOS animates it with Core Animation, Android and Apple Maps move it on a frame timer, and web moves it every animation frame. The map draws it, so it stays in place through pans and zooms. + +```tsx +const path = useMemo( + () => ({ + coordinates: route, // keyframe positions + times: route.map((_, i) => i * 4000), // ms after the first keyframe + rotations: headings, // optional, degrees + startTime: lapStart, // Date.now() at the first keyframe + loop: true, + }), + [route, headings, lapStart] +); + + + + +``` + +- The marker moves in a straight line between keyframes, so add keyframes through curves and corners. Each segment turns the short way. +- Positions follow the wall clock: every device shows a marker at the same place at the same time, and a new path with the same timing continues where the marker is. +- Pass the same `path` object across renders. A new object is sent to native again. +- Presses report where the marker is on its path, and `centerOnPress` centers there. +- `pathPositionAt(path, time)` returns where a marker is at any moment, with the same math as native, so JavaScript can follow progress without events. For example, trim a route behind a vehicle from a timer at its keyframe times. +- To redraw the map less often while markers move and the camera is still, set the `MapView`'s [`idleFrameRate`](./map-view.mdx#mapidleframerate). + +### MarkerPath + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `coordinates` | `Coordinate[]` | **required** | Keyframe positions | +| `times` | `number[]` | **required** | Milliseconds from the start of the path for each keyframe, in increasing order | +| `rotations` | `number[]` | - | Degrees clockwise from north for each keyframe. Each segment turns the short way | +| `startTime` | `number` | **required** | Wall-clock time in milliseconds, as from `Date.now()`, at which the marker is at the first keyframe | +| `loop` | `boolean` | `false` | Repeat the path. Otherwise the marker waits at the first keyframe until `startTime` and stays at the last one after the path ends | + +### pathPositionAt + +```ts +pathPositionAt(path: MarkerPath, time: number): { + coordinate: Coordinate; + rotation?: number; // [0, 360), when the path has rotations + index: number; // last keyframe passed, within the current lap +} | null +``` + +Returns `null` unless the path has at least two keyframes and a time for each. + ## Custom Markers Use the `children` prop to render a custom marker view. The `anchor` prop controls the point that is placed at the coordinate. diff --git a/ios/LuggMapView.mm b/ios/LuggMapView.mm index 27193a4..9b7b687 100644 --- a/ios/LuggMapView.mm +++ b/ios/LuggMapView.mm @@ -63,6 +63,7 @@ @implementation LuggMapView { BOOL _userLocationEnabled; LuggMapViewMapType _mapType; LuggMapViewTheme _theme; + LuggMapViewIdleFrameRate _idleFrameRate; BOOL _poiEnabled; LuggMapViewPoiFilterMode _poiFilterMode; NSArray *_poiFilterCategories; @@ -98,6 +99,7 @@ - (instancetype)initWithFrame:(CGRect)frame { _poiFilterCategories = @[]; _mapType = LuggMapViewMapType::Standard; _theme = LuggMapViewTheme::System; + _idleFrameRate = LuggMapViewIdleFrameRate::Maximum; _edgeInsets = UIEdgeInsetsZero; _insetAdjustment = LuggMapViewInsetAdjustment::Never; } @@ -457,6 +459,7 @@ - (void)applyProps { [_provider setUserLocationEnabled:_userLocationEnabled]; [_provider setMapType:_mapType]; [_provider setTheme:_theme]; + [_provider setIdleFrameRate:_idleFrameRate]; [_provider setPoiEnabled:_poiEnabled]; [_provider setPoiFilterMode:_poiFilterMode]; [_provider setPoiFilterCategories:_poiFilterCategories]; @@ -542,6 +545,10 @@ - (void)updateProps:(Props::Shared const &)props _theme = newViewProps.theme; [_provider setTheme:_theme]; } + if (newViewProps.idleFrameRate != prevViewProps.idleFrameRate) { + _idleFrameRate = newViewProps.idleFrameRate; + [_provider setIdleFrameRate:_idleFrameRate]; + } if (newViewProps.insetAdjustment != prevViewProps.insetAdjustment) { _insetAdjustment = newViewProps.insetAdjustment; [_provider setInsetAdjustment:_insetAdjustment]; diff --git a/ios/LuggMarkerView.h b/ios/LuggMarkerView.h index d51e94a..5db4e41 100644 --- a/ios/LuggMarkerView.h +++ b/ios/LuggMarkerView.h @@ -7,6 +7,33 @@ NS_ASSUME_NONNULL_BEGIN @class LuggMarkerView; @class LuggCalloutView; +static inline double LuggWallClockMilliseconds(void) { + return (CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) * 1000; +} + +// A timed path from the `path*` props. Immutable: a new object means a new path. +@interface LuggMarkerPath : NSObject + +@property(nonatomic, readonly) NSUInteger count; +@property(nonatomic, readonly) const CLLocationCoordinate2D *coordinates; +// Milliseconds after the first keyframe. +@property(nonatomic, readonly) const double *times; +// Degrees, or NULL when the path has no rotations. +@property(nonatomic, readonly, nullable) const double *rotations; +// Wall-clock milliseconds at the first keyframe. +@property(nonatomic, readonly) double startTime; +// Milliseconds from the first keyframe to the last. +@property(nonatomic, readonly) double duration; +@property(nonatomic, readonly) BOOL loop; + +// Milliseconds into the path at wall-clock `now`, wrapped into a lap when looping. +- (double)progressAt:(double)now; +// Position at wall-clock `now`. Sets `rotation` only when the path has rotations. +- (CLLocationCoordinate2D)coordinateAt:(double)now + rotation:(nullable CLLocationDegrees *)rotation; + +@end + @protocol LuggMarkerViewDelegate @optional - (void)markerViewDidLayout:(LuggMarkerView *)markerView; @@ -28,6 +55,7 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, readonly) BOOL rasterize; @property(nonatomic, readonly) BOOL centerOnPress; @property(nonatomic, readonly) BOOL draggable; +@property(nonatomic, readonly, nullable) LuggMarkerPath *path; @property(nonatomic, readonly) BOOL hasCustomView; @property(nonatomic, readonly) BOOL didLayout; @property(nonatomic, readonly) UIView *iconView; diff --git a/ios/LuggMarkerView.mm b/ios/LuggMarkerView.mm index a028903..b146e11 100644 --- a/ios/LuggMarkerView.mm +++ b/ios/LuggMarkerView.mm @@ -13,6 +13,123 @@ using namespace facebook::react; using namespace luggmaps::events; +static BOOL LuggMarkerPathPropsActive(const LuggMarkerViewProps &props) { + return props.pathCoordinates.size() >= 2 && + props.pathTimes.size() == props.pathCoordinates.size(); +} + +@interface LuggMarkerPath () +- (nullable instancetype)initWithProps:(const LuggMarkerViewProps &)props; +- (BOOL)matchesProps:(const LuggMarkerViewProps &)props; +@end + +@implementation LuggMarkerPath { + std::vector _coordinateValues; + std::vector _timeValues; + std::vector _rotationValues; + std::vector _rotationProps; +} + +- (instancetype)initWithProps:(const LuggMarkerViewProps &)props { + if (!LuggMarkerPathPropsActive(props)) { + return nil; + } + if (self = [super init]) { + size_t count = props.pathCoordinates.size(); + double first = props.pathTimes.front(); + _coordinateValues.reserve(count); + _timeValues.reserve(count); + for (size_t i = 0; i < count; i++) { + _coordinateValues.push_back(CLLocationCoordinate2DMake( + props.pathCoordinates[i].latitude, props.pathCoordinates[i].longitude)); + _timeValues.push_back(props.pathTimes[i] - first); + } + if (props.pathRotations.size() == count) { + // Unwrapped so each segment turns the short way (350 then 10 is +20, not -340). + _rotationProps = props.pathRotations; + _rotationValues.reserve(count); + double rotation = props.pathRotations[0]; + _rotationValues.push_back(rotation); + for (size_t i = 1; i < count; i++) { + rotation += fmod(fmod(props.pathRotations[i] - rotation, 360) + 540, 360) - 180; + _rotationValues.push_back(rotation); + } + } + _startTime = props.pathStartTime; + _loop = props.pathLoop; + _duration = _timeValues.back(); + } + return self; +} + +- (BOOL)matchesProps:(const LuggMarkerViewProps &)props { + size_t count = _coordinateValues.size(); + if (props.pathCoordinates.size() != count || props.pathTimes.size() != count || + props.pathStartTime != _startTime || props.pathLoop != _loop || + (props.pathRotations.size() == count) == _rotationValues.empty()) { + return NO; + } + double first = props.pathTimes.front(); + for (size_t i = 0; i < count; i++) { + if (props.pathCoordinates[i].latitude != _coordinateValues[i].latitude || + props.pathCoordinates[i].longitude != _coordinateValues[i].longitude || + props.pathTimes[i] - first != _timeValues[i]) { + return NO; + } + } + return _rotationValues.empty() || props.pathRotations == _rotationProps; +} + +- (NSUInteger)count { + return _coordinateValues.size(); +} + +- (const CLLocationCoordinate2D *)coordinates { + return _coordinateValues.data(); +} + +- (const double *)times { + return _timeValues.data(); +} + +- (const double *)rotations { + return _rotationValues.empty() ? NULL : _rotationValues.data(); +} + +- (double)progressAt:(double)now { + double elapsed = now - _startTime; + if (_loop && _duration > 0) { + return fmod(fmod(elapsed, _duration) + _duration, _duration); + } + return MIN(MAX(elapsed, 0), _duration); +} + +- (CLLocationCoordinate2D)coordinateAt:(double)now + rotation:(CLLocationDegrees *)rotation { + double t = [self progressAt:now]; + size_t lo = 0; + size_t hi = _timeValues.size() - 1; + while (hi - lo > 1) { + size_t mid = (lo + hi) / 2; + if (_timeValues[mid] <= t) { + lo = mid; + } else { + hi = mid; + } + } + double span = _timeValues[hi] - _timeValues[lo]; + double f = span > 0 ? (t - _timeValues[lo]) / span : 0; + if (rotation && !_rotationValues.empty()) { + *rotation = _rotationValues[lo] + (_rotationValues[hi] - _rotationValues[lo]) * f; + } + const CLLocationCoordinate2D &a = _coordinateValues[lo]; + const CLLocationCoordinate2D &b = _coordinateValues[hi]; + return CLLocationCoordinate2DMake(a.latitude + (b.latitude - a.latitude) * f, + a.longitude + (b.longitude - a.longitude) * f); +} + +@end + @interface LuggMarkerView () @end @@ -28,6 +145,7 @@ @implementation LuggMarkerView { BOOL _rasterize; BOOL _centerOnPress; BOOL _draggable; + LuggMarkerPath *_path; BOOL _didLayout; UIView *_iconView; LuggCalloutView *_calloutView; @@ -91,6 +209,10 @@ - (void)updateProps:(Props::Shared const &)props _rasterize = newViewProps.rasterize; _centerOnPress = newViewProps.centerOnPress; _draggable = newViewProps.draggable; + if (_path ? ![_path matchesProps:newViewProps] + : LuggMarkerPathPropsActive(newViewProps)) { + _path = [[LuggMarkerPath alloc] initWithProps:newViewProps]; + } } - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { @@ -200,6 +322,10 @@ - (BOOL)draggable { return _draggable; } +- (LuggMarkerPath *)path { + return _path; +} + - (BOOL)hasCustomView { return _iconView.subviews.count > 0; } diff --git a/ios/core/AppleMapProvider.mm b/ios/core/AppleMapProvider.mm index 07aedc1..82384cb 100644 --- a/ios/core/AppleMapProvider.mm +++ b/ios/core/AppleMapProvider.mm @@ -1,5 +1,6 @@ #import "AppleMapProvider.h" +using facebook::react::LuggMapViewIdleFrameRate; using facebook::react::LuggMapViewInsetAdjustment; using facebook::react::LuggMapViewMapType; using facebook::react::LuggMapViewPoiFilterMode; @@ -16,6 +17,13 @@ #import "LuggAnnotationView.h" #import "MKPolylineAnimator.h" #import "MapEdgeInsets.h" +#import "MarkerPathTicker.h" + +static CLLocationDegrees LuggMarkerRotation(LuggMarkerView *markerView) { + CLLocationDegrees rotation = markerView.rotate; + [markerView.path coordinateAt:LuggWallClockMilliseconds() rotation:&rotation]; + return rotation; +} @interface AppleMarkerAnnotation : NSObject @property(nonatomic, assign) CLLocationCoordinate2D coordinate; @@ -116,6 +124,10 @@ @interface AppleMapProvider () < @end @implementation AppleMapProvider { + // MapKit can't animate annotations along a path, so the ticker moves them. + NSHashTable *_pathAnnotations; + MarkerPathTicker *_pathTicker; + float _pathFramesPerSecond; UIView *_wrapperView; LuggAppleMapViewContent *_mapView; BOOL _isMapReady; @@ -150,6 +162,7 @@ @implementation AppleMapProvider { - (instancetype)init { if (self = [super init]) { + _pathAnnotations = [NSHashTable weakObjectsHashTable]; _overlayToPolylineMap = [NSMapTable strongToWeakObjectsMapTable]; _overlayToPolygonMap = [NSMapTable strongToWeakObjectsMapTable]; _overlayToCircleMap = [NSMapTable strongToWeakObjectsMapTable]; @@ -218,6 +231,9 @@ - (void)initializeMapInView:(UIView *)wrapperView } - (void)destroy { + [_pathTicker stop]; + _pathTicker = nil; + [_pathAnnotations removeAllObjects]; [self destroyMapView]; _isMapReady = NO; } @@ -440,6 +456,21 @@ - (void)setMapType:(LuggMapViewMapType)mapType { _mapView.mapType = LuggMKMapTypeFromMapType(mapType); } +- (void)setIdleFrameRate:(LuggMapViewIdleFrameRate)idleFrameRate { + switch (idleFrameRate) { + case LuggMapViewIdleFrameRate::PowerSave: + _pathFramesPerSecond = 15; + break; + case LuggMapViewIdleFrameRate::Conservative: + _pathFramesPerSecond = 30; + break; + case LuggMapViewIdleFrameRate::Maximum: + _pathFramesPerSecond = 0; + break; + } + _pathTicker.framesPerSecond = _pathFramesPerSecond; +} + - (void)setTheme:(LuggMapViewTheme)theme { switch (theme) { case LuggMapViewTheme::Dark: @@ -1172,7 +1203,15 @@ - (void)markerViewDidUpdate:(LuggMarkerView *)markerView { if (!annotation) return; - annotation.coordinate = markerView.coordinate; + LuggMarkerPath *path = markerView.path; + if (path) { + annotation.coordinate = [path coordinateAt:LuggWallClockMilliseconds() rotation:NULL]; + [_pathAnnotations addObject:annotation]; + } else { + annotation.coordinate = markerView.coordinate; + [_pathAnnotations removeObject:annotation]; + } + [self updatePathTicker]; annotation.title = markerView.title; annotation.subtitle = markerView.markerDescription; @@ -1249,6 +1288,8 @@ - (void)removeMarkerView:(LuggMarkerView *)markerView { annotation.annotationView = nil; [_mapView removeAnnotation:annotation]; markerView.marker = nil; + [_pathAnnotations removeObject:annotation]; + [self updatePathTicker]; } [markerView resetIconViewTransform]; } @@ -1304,7 +1345,7 @@ - (void)applyMarkerStyle:(LuggMarkerView *)markerView annotationView.centerOffset = CGPointMake(scaledWidth * (0.5 - anchor.x), scaledHeight * (0.5 - anchor.y)); annotationView.transform = - CGAffineTransformMakeRotation(markerView.rotate * M_PI / 180.0); + CGAffineTransformMakeRotation(LuggMarkerRotation(markerView) * M_PI / 180.0); // Debounce: rasterize once updates settle if (markerView.rasterize) { @@ -1323,6 +1364,42 @@ - (void)applyMarkerStyle:(LuggMarkerView *)markerView } } +- (void)updatePathTicker { + if (_pathAnnotations.count == 0) { + [_pathTicker stop]; + return; + } + if (!_pathTicker) { + __weak AppleMapProvider *weakSelf = self; + _pathTicker = [[MarkerPathTicker alloc] initWithTick:^{ + [weakSelf tickPathMarkers]; + }]; + } + _pathTicker.framesPerSecond = _pathFramesPerSecond; + [_pathTicker start]; +} + +- (void)tickPathMarkers { + double now = LuggWallClockMilliseconds(); + for (AppleMarkerAnnotation *annotation in _pathAnnotations) { + LuggMarkerView *markerView = annotation.markerView; + LuggMarkerPath *path = markerView.path; + if (!path) { + continue; + } + CLLocationDegrees rotation = markerView.rotate; + CLLocationCoordinate2D coordinate = [path coordinateAt:now rotation:&rotation]; + if (coordinate.latitude != annotation.coordinate.latitude || + coordinate.longitude != annotation.coordinate.longitude) { + annotation.coordinate = coordinate; + } + if (path.rotations) { + annotation.annotationView.transform = + CGAffineTransformMakeRotation(rotation * M_PI / 180.0); + } + } +} + - (void)updateAnnotationViewFrame:(AppleMarkerAnnotation *)annotation { MKAnnotationView *annotationView = annotation.annotationView; LuggMarkerView *markerView = annotation.markerView; @@ -1777,6 +1854,7 @@ - (void)pauseAnimations { MKPolylineAnimator *renderer = (MKPolylineAnimator *)polylineView.renderer; [renderer pause]; } + _pathTicker.paused = YES; } - (void)resumeAnimations { @@ -1785,6 +1863,7 @@ - (void)resumeAnimations { MKPolylineAnimator *renderer = (MKPolylineAnimator *)polylineView.renderer; [renderer resume]; } + _pathTicker.paused = NO; } #pragma mark - Commands diff --git a/ios/core/GoogleMapProvider.mm b/ios/core/GoogleMapProvider.mm index 2a76596..49b19fb 100644 --- a/ios/core/GoogleMapProvider.mm +++ b/ios/core/GoogleMapProvider.mm @@ -1,5 +1,6 @@ #import "GoogleMapProvider.h" +using facebook::react::LuggMapViewIdleFrameRate; using facebook::react::LuggMapViewInsetAdjustment; using facebook::react::LuggMapViewMapType; using facebook::react::LuggMapViewPoiFilterMode; @@ -14,9 +15,28 @@ #import "../LuggTileOverlayView.h" #import "GMSPolylineAnimator.h" #import "MapEdgeInsets.h" +#import "MarkerPathTicker.h" #import "PolylineAnimatorBase.h" static NSString *const kDemoMapId = @"DEMO_MAP_ID"; +static NSString *const kPathLatitudeAnimationKey = @"luggPathLatitude"; +static NSString *const kPathLongitudeAnimationKey = @"luggPathLongitude"; +static NSString *const kPathRotationAnimationKey = @"luggPathRotation"; +// Core Animation scans every keyframe each frame, so each marker animates only the next minute of +// its path, and a timer replaces those animations twice a minute. +static const double kPathWindowMilliseconds = 60000; +static const NSTimeInterval kPathRefreshInterval = 30; + +static float LuggFramesPerSecond(GMSFrameRate frameRate) { + switch (frameRate) { + case kGMSFrameRatePowerSave: + return 15; + case kGMSFrameRateConservative: + return 30; + default: + return 0; + } +} GMSMapID *LuggGMSMapIDFromString(NSString *_Nullable mapId) { if (mapId.length == 0 || [mapId isEqualToString:kDemoMapId]) { @@ -78,6 +98,16 @@ @implementation GoogleMapProvider { *_groundOverlayToViewMap; LuggMarkerView *_activeNonBubbledMarker; + // Path markers and the idle frame rate + NSMapTable *_markerPaths; + NSHashTable *_tickedMarkers; + NSHashTable *_animatedMarkers; + NSTimer *_pathRefreshTimer; + MarkerPathTicker *_pathTicker; + GMSFrameRate _idleFrameRate; + BOOL _cameraMoving; + dispatch_block_t _pendingIdleFrameRate; + // Edge insets animation CADisplayLink *_edgeInsetsDisplayLink; UIEdgeInsets _edgeInsetsFrom; @@ -104,6 +134,10 @@ - (instancetype)init { _circleToViewMap = [NSMapTable strongToWeakObjectsMapTable]; _markerToViewMap = [NSMapTable strongToWeakObjectsMapTable]; _groundOverlayToViewMap = [NSMapTable strongToWeakObjectsMapTable]; + _markerPaths = [NSMapTable weakToStrongObjectsMapTable]; + _tickedMarkers = [NSHashTable weakObjectsHashTable]; + _animatedMarkers = [NSHashTable weakObjectsHashTable]; + _idleFrameRate = kGMSFrameRateMaximum; } return self; } @@ -140,6 +174,7 @@ - (void)initializeMapInView:(UIView *)wrapperView _mapView.delegate = self; _mapView.paddingAdjustmentBehavior = kGMSMapViewPaddingAdjustmentBehaviorNever; + _mapView.preferredFrameRate = _idleFrameRate; _wrapperView = wrapperView; [wrapperView addSubview:_mapView]; @@ -159,6 +194,17 @@ - (void)initializeMapInView:(UIView *)wrapperView - (void)destroy { [self stopEdgeInsetsAnimation]; + if (_pendingIdleFrameRate) { + dispatch_block_cancel(_pendingIdleFrameRate); + _pendingIdleFrameRate = nil; + } + [_pathTicker stop]; + _pathTicker = nil; + [_pathRefreshTimer invalidate]; + _pathRefreshTimer = nil; + [_markerPaths removeAllObjects]; + [_tickedMarkers removeAllObjects]; + [_animatedMarkers removeAllObjects]; [_pendingMarkerViews removeAllObjects]; [_pendingPolylineViews removeAllObjects]; [_pendingPolygonViews removeAllObjects]; @@ -247,6 +293,58 @@ - (void)applyTheme { _mapView.overrideUserInterfaceStyle = LuggInterfaceStyleFromTheme(_theme); } +- (void)setIdleFrameRate:(LuggMapViewIdleFrameRate)idleFrameRate { + switch (idleFrameRate) { + case LuggMapViewIdleFrameRate::PowerSave: + _idleFrameRate = kGMSFrameRatePowerSave; + break; + case LuggMapViewIdleFrameRate::Conservative: + _idleFrameRate = kGMSFrameRateConservative; + break; + case LuggMapViewIdleFrameRate::Maximum: + _idleFrameRate = kGMSFrameRateMaximum; + break; + } + _pathTicker.framesPerSecond = LuggFramesPerSecond(_idleFrameRate); + if (!_cameraMoving) { + _mapView.preferredFrameRate = _idleFrameRate; + } +} + +// The map renders at full rate while the camera moves, and drops back to the idle rate after a +// short hold so a double-tap zoom and its follow-through stay smooth. +- (void)cameraWillMove { + _cameraMoving = YES; + if (_pendingIdleFrameRate) { + dispatch_block_cancel(_pendingIdleFrameRate); + _pendingIdleFrameRate = nil; + } + _mapView.preferredFrameRate = kGMSFrameRateMaximum; +} + +- (void)cameraDidIdle { + _cameraMoving = NO; + if (_idleFrameRate == kGMSFrameRateMaximum || _edgeInsetsDisplayLink) { + return; + } + if (_pendingIdleFrameRate) { + dispatch_block_cancel(_pendingIdleFrameRate); + } + __weak GoogleMapProvider *weakSelf = self; + _pendingIdleFrameRate = dispatch_block_create((dispatch_block_flags_t)0, ^{ + [weakSelf applyIdleFrameRate]; + }); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(300 * NSEC_PER_MSEC)), + dispatch_get_main_queue(), _pendingIdleFrameRate); +} + +- (void)applyIdleFrameRate { + _pendingIdleFrameRate = nil; + if (!_cameraMoving) { + _mapView.preferredFrameRate = _idleFrameRate; + } +} + - (void)setMinZoom:(double)minZoom { if (!_mapView) return; @@ -285,6 +383,7 @@ - (void)setEdgeInsets:(UIEdgeInsets)edgeInsets _edgeInsetsDisplayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(edgeInsetsAnimationTick:)]; + [self cameraWillMove]; [_edgeInsetsDisplayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; } else { @@ -308,13 +407,18 @@ - (void)edgeInsetsAnimationTick:(CADisplayLink *)displayLink { } - (void)stopEdgeInsetsAnimation { + if (!_edgeInsetsDisplayLink) { + return; + } [_edgeInsetsDisplayLink invalidate]; _edgeInsetsDisplayLink = nil; + [self cameraDidIdle]; } #pragma mark - GMSMapViewDelegate - (void)mapView:(GMSMapView *)mapView willMove:(BOOL)gesture { + [self cameraWillMove]; _isDragging = gesture; if (_isDragging) { for (GMSPolylineAnimator *animator in _polylineAnimators.objectEnumerator) { @@ -334,6 +438,7 @@ - (void)mapView:(GMSMapView *)mapView - (void)mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *)position { + [self cameraDidIdle]; BOOL wasDragging = _isDragging; _isDragging = NO; if (wasDragging) { @@ -394,13 +499,19 @@ - (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker { LuggMarkerView *markerView = [_markerToViewMap objectForKey:marker]; if (markerView) { - CGPoint point = [_mapView.projection pointForCoordinate:marker.position]; + CLLocationCoordinate2D position = marker.position; + LuggMarkerPath *path = [_markerPaths objectForKey:marker]; + if (path) { + position = [path coordinateAt:LuggWallClockMilliseconds() rotation:NULL]; + [markerView updateCoordinate:position]; + } + CGPoint point = [_mapView.projection pointForCoordinate:position]; [markerView emitPressEventWithPoint:point]; LuggCalloutView *calloutView = markerView.calloutView; if (calloutView && calloutView.hasCustomContent) { if (markerView.centerOnPress) { - [mapView animateToLocation:marker.position]; + [mapView animateToLocation:position]; } mapView.selectedMarker = marker; @@ -412,6 +523,12 @@ - (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker { return YES; } + // The SDK would center on the marker's model position, which a path animation doesn't move. + if (path && markerView.centerOnPress) { + [mapView animateToLocation:position]; + return YES; + } + if (!markerView.centerOnPress) { mapView.selectedMarker = marker; return YES; @@ -622,6 +739,10 @@ - (void)removeMarkerView:(LuggMarkerView *)markerView { GMSAdvancedMarker *marker = (GMSAdvancedMarker *)markerView.marker; if (marker) { [_markerToViewMap removeObjectForKey:marker]; + [_markerPaths removeObjectForKey:marker]; + [_tickedMarkers removeObject:marker]; + [_animatedMarkers removeObject:marker]; + [self updatePathTicker]; marker.iconView = nil; marker.map = nil; markerView.marker = nil; @@ -643,7 +764,9 @@ - (void)syncMarkerView:(LuggMarkerView *)markerView { } GMSAdvancedMarker *marker = (GMSAdvancedMarker *)markerView.marker; - marker.position = markerView.coordinate; + if (!markerView.path) { + marker.position = markerView.coordinate; + } marker.title = markerView.title.length > 0 ? markerView.title : nil; marker.snippet = markerView.markerDescription.length > 0 ? markerView.markerDescription @@ -651,6 +774,7 @@ - (void)syncMarkerView:(LuggMarkerView *)markerView { marker.zIndex = (int)markerView.zIndex; marker.draggable = markerView.draggable; [self applyMarkerStyle:markerView marker:marker]; + [self applyPathForMarkerView:markerView marker:marker]; } - (void)processPendingMarkers { @@ -686,6 +810,7 @@ - (void)addMarkerViewToMap:(LuggMarkerView *)markerView { marker.map = _mapView; markerView.marker = marker; [_markerToViewMap setObject:markerView forKey:marker]; + [self applyPathForMarkerView:markerView marker:marker]; } - (void)applyMarkerStyle:(LuggMarkerView *)markerView @@ -694,29 +819,204 @@ - (void)applyMarkerStyle:(LuggMarkerView *)markerView if (markerView.rasterize) { marker.iconView = nil; marker.icon = [markerView createScaledIconImage]; - marker.rotation = markerView.rotate; + if (!markerView.path.rotations) { + marker.rotation = markerView.rotate; + } } else { UIView *iconView = markerView.iconView; if (marker.iconView != iconView) { [iconView removeFromSuperview]; marker.iconView = iconView; } - CGFloat scale = markerView.scale; - CGFloat radians = markerView.rotate * M_PI / 180.0; - iconView.transform = - CGAffineTransformConcat(CGAffineTransformMakeScale(scale, scale), - CGAffineTransformMakeRotation(radians)); + CLLocationDegrees rotation = markerView.rotate; + [markerView.path coordinateAt:LuggWallClockMilliseconds() rotation:&rotation]; + [self applyIconViewTransform:markerView rotation:rotation]; marker.rotation = 0; } marker.groundAnchor = markerView.anchor; } else { marker.iconView = nil; marker.icon = nil; - marker.rotation = markerView.rotate; + if (!markerView.path.rotations) { + marker.rotation = markerView.rotate; + } marker.groundAnchor = CGPointMake(0.5, 1); } } +- (void)applyIconViewTransform:(LuggMarkerView *)markerView + rotation:(CLLocationDegrees)rotation { + CGFloat scale = markerView.scale; + markerView.iconView.transform = + CGAffineTransformConcat(CGAffineTransformMakeScale(scale, scale), + CGAffineTransformMakeRotation(rotation * M_PI / 180.0)); +} + +#pragma mark - Path markers + +// Markers with an image follow their path with Core Animation on the marker layer, so the map moves +// them on its own frames. Icon-view markers ignore those animations, so the ticker moves them. +- (void)applyPathForMarkerView:(LuggMarkerView *)markerView marker:(GMSMarker *)marker { + LuggMarkerPath *path = markerView.path; + BOOL ticked = path && marker.iconView; + if ([_markerPaths objectForKey:marker] == path && + [_tickedMarkers containsObject:marker] == ticked) { + return; + } + + [marker.layer removeAnimationForKey:kPathLatitudeAnimationKey]; + [marker.layer removeAnimationForKey:kPathLongitudeAnimationKey]; + [marker.layer removeAnimationForKey:kPathRotationAnimationKey]; + [_tickedMarkers removeObject:marker]; + [_animatedMarkers removeObject:marker]; + + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + if (path) { + [_markerPaths setObject:path forKey:marker]; + double now = LuggWallClockMilliseconds(); + CLLocationDegrees rotation = markerView.rotate; + marker.position = [path coordinateAt:now rotation:&rotation]; + if (ticked) { + [_tickedMarkers addObject:marker]; + } else { + if (path.rotations) { + marker.rotation = rotation; + } + [_animatedMarkers addObject:marker]; + [self animateMarker:marker alongPath:path now:now]; + } + } else { + [_markerPaths removeObjectForKey:marker]; + marker.position = markerView.coordinate; + } + [CATransaction commit]; + [self updatePathTicker]; +} + +- (void)animateMarker:(GMSMarker *)marker alongPath:(LuggMarkerPath *)path now:(double)now { + if (path.duration <= 0) { + return; + } + NSMutableArray *keyTimes = [NSMutableArray array]; + NSMutableArray *latitudes = [NSMutableArray array]; + NSMutableArray *longitudes = [NSMutableArray array]; + NSMutableArray *rotations = [NSMutableArray array]; + void (^add)(double, CLLocationCoordinate2D, CLLocationDegrees) = + ^(double at, CLLocationCoordinate2D coordinate, CLLocationDegrees rotation) { + [keyTimes addObject:@((at - now) / kPathWindowMilliseconds)]; + [latitudes addObject:@(coordinate.latitude)]; + [longitudes addObject:@(coordinate.longitude)]; + [rotations addObject:@(rotation)]; + }; + + // Where the marker is now, the keyframes in the next minute (unrolling laps when the path + // loops), and where it is at the end of that minute. + CLLocationDegrees rotation = 0; + add(now, [path coordinateAt:now rotation:&rotation], rotation); + double end = now + kPathWindowMilliseconds; + double lapStart = path.loop ? now - [path progressAt:now] : path.startTime; + for (; lapStart < end; lapStart += path.duration) { + for (NSUInteger i = 0; i < path.count; i++) { + double at = lapStart + path.times[i]; + if (at > now && at < end) { + add(at, path.coordinates[i], path.rotations ? path.rotations[i] : 0); + } + } + if (!path.loop) { + break; + } + } + CLLocationDegrees endRotation = rotation; + CLLocationCoordinate2D endCoordinate = [path coordinateAt:end rotation:&endRotation]; + if (path.loop && path.rotations) { + // coordinateAt: unwraps rotations within a lap; continue from the last keyframe instead. + CLLocationDegrees previous = rotations.lastObject.doubleValue; + endRotation = previous + fmod(fmod(endRotation - previous, 360) + 540, 360) - 180; + } + add(end, endCoordinate, endRotation); + + CFTimeInterval beginTime = [marker.layer convertTime:CACurrentMediaTime() fromLayer:nil]; + void (^animate)(NSString *, NSArray *, NSString *) = + ^(NSString *keyPath, NSArray *values, NSString *key) { + CAKeyframeAnimation *animation = [CAKeyframeAnimation animationWithKeyPath:keyPath]; + animation.values = values; + animation.keyTimes = keyTimes; + animation.calculationMode = kCAAnimationLinear; + animation.duration = kPathWindowMilliseconds / 1000; + animation.beginTime = beginTime; + animation.fillMode = kCAFillModeForwards; + animation.removedOnCompletion = NO; + [marker.layer addAnimation:animation forKey:key]; + }; + animate(kGMSMarkerLayerLatitude, latitudes, kPathLatitudeAnimationKey); + animate(kGMSMarkerLayerLongitude, longitudes, kPathLongitudeAnimationKey); + if (path.rotations) { + animate(kGMSMarkerLayerRotation, rotations, kPathRotationAnimationKey); + } +} + +- (void)refreshPathAnimations { + double now = LuggWallClockMilliseconds(); + for (GMSMarker *marker in _animatedMarkers) { + LuggMarkerPath *path = [_markerPaths objectForKey:marker]; + if (path) { + [self animateMarker:marker alongPath:path now:now]; + } + } +} + +- (void)updatePathTicker { + if (_animatedMarkers.count == 0) { + [_pathRefreshTimer invalidate]; + _pathRefreshTimer = nil; + } else if (!_pathRefreshTimer) { + __weak GoogleMapProvider *weakSelf = self; + _pathRefreshTimer = [NSTimer timerWithTimeInterval:kPathRefreshInterval + repeats:YES + block:^(NSTimer *timer) { + [weakSelf refreshPathAnimations]; + }]; + [[NSRunLoop mainRunLoop] addTimer:_pathRefreshTimer forMode:NSRunLoopCommonModes]; + } + + if (_tickedMarkers.count == 0) { + [_pathTicker stop]; + return; + } + if (!_pathTicker) { + __weak GoogleMapProvider *weakSelf = self; + _pathTicker = [[MarkerPathTicker alloc] initWithTick:^{ + [weakSelf tickPathMarkers]; + }]; + } + _pathTicker.framesPerSecond = LuggFramesPerSecond(_idleFrameRate); + [_pathTicker start]; +} + +- (void)tickPathMarkers { + double now = LuggWallClockMilliseconds(); + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + for (GMSMarker *marker in _tickedMarkers) { + LuggMarkerView *markerView = [_markerToViewMap objectForKey:marker]; + LuggMarkerPath *path = markerView.path; + if (!path) { + continue; + } + CLLocationDegrees rotation = markerView.rotate; + CLLocationCoordinate2D position = [path coordinateAt:now rotation:&rotation]; + if (position.latitude != marker.position.latitude || + position.longitude != marker.position.longitude) { + marker.position = position; + } + if (path.rotations) { + [self applyIconViewTransform:markerView rotation:rotation]; + } + } + [CATransaction commit]; +} + #pragma mark - Polyline Management - (void)addPolylineView:(LuggPolylineView *)polylineView { @@ -1123,12 +1423,16 @@ - (void)pauseAnimations { for (GMSPolylineAnimator *animator in _polylineAnimators.objectEnumerator) { [animator pause]; } + _pathTicker.paused = YES; } - (void)resumeAnimations { for (GMSPolylineAnimator *animator in _polylineAnimators.objectEnumerator) { [animator resume]; } + _pathTicker.paused = NO; + // Windows that ran out in the background would hold their last position. + [self refreshPathAnimations]; } #pragma mark - Commands diff --git a/ios/core/MapProviderDelegate.h b/ios/core/MapProviderDelegate.h index 4d94be9..b15454d 100644 --- a/ios/core/MapProviderDelegate.h +++ b/ios/core/MapProviderDelegate.h @@ -58,6 +58,8 @@ NS_ASSUME_NONNULL_BEGIN - (void)setUserLocationEnabled:(BOOL)enabled; - (void)setMapType:(facebook::react::LuggMapViewMapType)mapType; - (void)setTheme:(facebook::react::LuggMapViewTheme)theme; +- (void)setIdleFrameRate: + (facebook::react::LuggMapViewIdleFrameRate)idleFrameRate; - (void)setMinZoom:(double)minZoom; - (void)setMaxZoom:(double)maxZoom; - (void)setEdgeInsets:(UIEdgeInsets)edgeInsets diff --git a/ios/core/MarkerPathTicker.h b/ios/core/MarkerPathTicker.h new file mode 100644 index 0000000..39d40db --- /dev/null +++ b/ios/core/MarkerPathTicker.h @@ -0,0 +1,18 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Calls `tick` from a display link while started, at up to `framesPerSecond` (0 for every frame). +// Moves path markers that Core Animation can't animate. +@interface MarkerPathTicker : NSObject + +@property(nonatomic) float framesPerSecond; +@property(nonatomic) BOOL paused; + +- (instancetype)initWithTick:(void (^)(void))tick; +- (void)start; +- (void)stop; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/core/MarkerPathTicker.mm b/ios/core/MarkerPathTicker.mm new file mode 100644 index 0000000..fe75782 --- /dev/null +++ b/ios/core/MarkerPathTicker.mm @@ -0,0 +1,78 @@ +#import "MarkerPathTicker.h" +#import + +@interface MarkerPathTicker () +- (void)fire; +@end + +// The display link retains its target; the proxy keeps that from retaining the ticker. +@interface MarkerPathTickerProxy : NSObject +@property(nonatomic, weak) MarkerPathTicker *ticker; +@end + +@implementation MarkerPathTickerProxy +- (void)fire:(CADisplayLink *)displayLink { + MarkerPathTicker *ticker = _ticker; + if (ticker) { + [ticker fire]; + } else { + [displayLink invalidate]; + } +} +@end + +@implementation MarkerPathTicker { + void (^_tick)(void); + CADisplayLink *_displayLink; +} + +- (instancetype)initWithTick:(void (^)(void))tick { + if (self = [super init]) { + _tick = [tick copy]; + } + return self; +} + +- (void)dealloc { + [_displayLink invalidate]; +} + +- (void)start { + if (_displayLink) { + return; + } + MarkerPathTickerProxy *proxy = [[MarkerPathTickerProxy alloc] init]; + proxy.ticker = self; + _displayLink = [CADisplayLink displayLinkWithTarget:proxy selector:@selector(fire:)]; + [self applyFrameRate]; + _displayLink.paused = _paused; + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +} + +- (void)stop { + [_displayLink invalidate]; + _displayLink = nil; +} + +- (void)setFramesPerSecond:(float)framesPerSecond { + _framesPerSecond = framesPerSecond; + [self applyFrameRate]; +} + +- (void)setPaused:(BOOL)paused { + _paused = paused; + _displayLink.paused = paused; +} + +- (void)applyFrameRate { + _displayLink.preferredFrameRateRange = + _framesPerSecond > 0 + ? CAFrameRateRangeMake(_framesPerSecond, _framesPerSecond, _framesPerSecond) + : CAFrameRateRangeDefault; +} + +- (void)fire { + _tick(); +} + +@end diff --git a/ios/core/StaticMapProviderBase.mm b/ios/core/StaticMapProviderBase.mm index 6884896..eaaf9a2 100644 --- a/ios/core/StaticMapProviderBase.mm +++ b/ios/core/StaticMapProviderBase.mm @@ -1,5 +1,6 @@ #import "StaticMapProviderBase.h" +using facebook::react::LuggMapViewIdleFrameRate; using facebook::react::LuggMapViewInsetAdjustment; using facebook::react::LuggMapViewMapType; using facebook::react::LuggMapViewPoiFilterMode; @@ -475,6 +476,9 @@ - (void)setTheme:(LuggMapViewTheme)theme { _theme = theme; } +- (void)setIdleFrameRate:(LuggMapViewIdleFrameRate)idleFrameRate { +} + // Interaction and camera props don't apply to a static map - (void)setInsetAdjustment:(LuggMapViewInsetAdjustment)insetAdjustment { } diff --git a/skills/maps-usage/SKILL.md b/skills/maps-usage/SKILL.md index e597016..64f6c02 100644 --- a/skills/maps-usage/SKILL.md +++ b/skills/maps-usage/SKILL.md @@ -250,6 +250,22 @@ const animatedProps = useAnimatedProps(() => ({ Works on every platform. Web markers round to whole pixels, so pass `rasterize={Platform.OS !== 'web'}` to make a marker moved every frame glide there. Full example in [Advanced Patterns](./references/advanced-patterns.md#animated-marker-along-a-route). +### Marker on a known route (`path`) + +When the route and timing are known ahead, hand the marker a `path` instead of animating `coordinate`: native code moves it with no React work per frame. + +```tsx +const path = useMemo(() => ({ coordinates, times, rotations, startTime, loop: true }), [coordinates, times, rotations, startTime]); + + + + + + +``` + +`times` are ms after the first keyframe, `startTime` is `Date.now()` at the first keyframe, and each segment of `rotations` turns the short way. Moves are linear between keyframes, so add keyframes through corners. `pathPositionAt(path, Date.now())` gives the position and keyframe index in JS (no events). `idleFrameRate="powerSave"` redraws a still map at 15 fps on Google iOS. + ## Rules That Save Debugging Time 1. **Size the map.** `MapView` renders nothing without `flex: 1`, a fixed height, or `absoluteFill`. @@ -290,6 +306,8 @@ Works on every platform. Web markers round to whole pixels, so pass `rasterize={ | Non-bubbled callout | Live view | Live view | Live view | Styled InfoWindow | | Marker `rasterize` | Yes | Yes | Yes | `false` = overlay positioned between pixels | | Reanimated `animatedProps` on `Marker` | Yes | Yes | Yes | Yes | +| Marker `path` | Frame timer | Core Animation (image markers), frame timer (`rasterize={false}`) | Frame timer | `requestAnimationFrame` | +| `MapView` `idleFrameRate` | Path timer rate | Map frame rate at rest | Path timer rate | No | | `Polyline` gradient + `animated` | Yes | Yes | Yes | Yes (JS) | | `GroundOverlay.bearing` | No | Yes | Yes | No | | `TileOverlay.onPress` | No | No | No | Yes | diff --git a/skills/maps-usage/references/configuration.md b/skills/maps-usage/references/configuration.md index 08c95db..792ad86 100644 --- a/skills/maps-usage/references/configuration.md +++ b/skills/maps-usage/references/configuration.md @@ -136,6 +136,7 @@ export function App() { | `maxZoom` | `number` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | Upper zoom clamp | | `edgeInsets` | `EdgeInsets` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | Viewport insets. Shifts the logical center, attribution, and controls. Prefer `setEdgeInsets()` for animated changes. Applied only when the value changes, so re-renders keep `setEdgeInsets()` insets | | `insetAdjustment` | `'never' \| 'automatic'` | `'never'` | πŸŽπŸ…ΆπŸ€– | `'automatic'` adds safe-area insets to the map padding | +| `idleFrameRate` | `'maximum' \| 'conservative' \| 'powerSave'` | `'maximum'` | πŸŽπŸ…ΆπŸ€– | Redraw rate while the camera is still, for `path` markers. Google iOS: map frame rate 60/30/15 fps (also slows animated polylines at rest); Android and Apple: path markers move every frame/30/15 times a second. Full rate while the camera moves | ### Gestures @@ -195,6 +196,7 @@ Static-mode behavior notes: | `rasterize` | `boolean` | `true` | πŸŽπŸ…ΆπŸ€–πŸŒ | Rasterize the custom view to a bitmap. `false` keeps a live view for content that animates internally. On web, `false` positions the custom view between pixels for markers moved every frame. Markers with `title`, `callout` or `draggable` keep whole pixels | | `centerOnPress` | `boolean` | `true` | πŸŽπŸ…ΆπŸ€–πŸŒ | Center the map on the marker when tapped | | `draggable` | `boolean` | `false` | πŸŽπŸ…ΆπŸ€–πŸŒ | Long-press-and-drag. Update `coordinate` from `onDragEnd` | +| `path` | `MarkerPath` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | `{ coordinates, times (ms from start), rotations?, startTime (Date.now() at the first keyframe), loop? }`. Native code moves the marker, linear between keyframes, each turn the short way, following the wall clock. Ignores `coordinate`, and `rotate` when `rotations` is set. Presses report the on-path position. `pathPositionAt(path, time)` gives the position in JS. Keep the object stable across renders | | `callout` | `ReactElement \| ComponentType` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | Custom callout content. Falls back to `title`/`description` bubble when omitted | | `calloutOptions` | `CalloutOptions` | `{ bubbled: true }` | πŸŽπŸ…ΆπŸ€–πŸŒ | `bubbled: false` renders the content as a live, interactive view without native chrome. `offset: Point` nudges a non-bubbled callout from its default centered-above position (positive `y` moves down) | | `name` | `string` | β€” | πŸŽπŸ…ΆπŸ€– | Debug label for native logs | diff --git a/src/MapView.tsx b/src/MapView.tsx index 12671ea..70ebf00 100644 --- a/src/MapView.tsx +++ b/src/MapView.tsx @@ -110,6 +110,7 @@ export class MapView poiFilter, theme, insetAdjustment, + idleFrameRate, onPress, onLongPress, onCameraMove, @@ -145,6 +146,7 @@ export class MapView poiFilterCategories={poiFilter?.categories} theme={theme} insetAdjustment={insetAdjustment} + idleFrameRate={idleFrameRate} onMapPress={onPress} onMapLongPress={onLongPress} onCameraMove={onCameraMove} diff --git a/src/MapView.types.ts b/src/MapView.types.ts index 1f5ece6..40e0a3e 100644 --- a/src/MapView.types.ts +++ b/src/MapView.types.ts @@ -18,6 +18,8 @@ export type MapTheme = 'light' | 'dark' | 'system'; export type InsetAdjustment = 'automatic' | 'never'; +export type MapIdleFrameRate = 'maximum' | 'conservative' | 'powerSave'; + export type PoiCategory = | 'airport' | 'amusement-park' @@ -275,6 +277,16 @@ export interface MapViewProps extends ViewProps { * @default 'never' */ insetAdjustment?: InsetAdjustment; + /** + * How often the map redraws while the camera is still, for markers that move along a `path`. + * The map redraws at full rate while the camera moves. + * - Google Maps on iOS: the map's frame rate at rest (60, 30 or 15 fps). It also slows other + * animations on the map while the camera is still. + * - Android and Apple Maps: how often `path` markers move (every frame, 30 or 15 times a second). + * - Web: ignored. + * @default 'maximum' + */ + idleFrameRate?: MapIdleFrameRate; /** * Called when the map is pressed */ diff --git a/src/__tests__/markerPath.test.ts b/src/__tests__/markerPath.test.ts new file mode 100644 index 0000000..3d73ef2 --- /dev/null +++ b/src/__tests__/markerPath.test.ts @@ -0,0 +1,60 @@ +import { pathPositionAt } from '../components/markerPath'; +import type { MarkerPath } from '../components/Marker.types'; + +const square: MarkerPath = { + coordinates: [ + { latitude: 0, longitude: 0 }, + { latitude: 1, longitude: 0 }, + { latitude: 1, longitude: 1 }, + { latitude: 0, longitude: 0 }, + ], + times: [0, 1000, 2000, 4000], + rotations: [350, 10, 90, 350], + startTime: 10_000, +}; + +it('interpolates between keyframes from the wall clock', () => { + expect(pathPositionAt(square, 10_500)).toEqual({ + coordinate: { latitude: 0.5, longitude: 0 }, + rotation: 0, + index: 0, + }); + expect(pathPositionAt(square, 11_500)?.coordinate).toEqual({ + latitude: 1, + longitude: 0.5, + }); + expect(pathPositionAt(square, 11_500)?.index).toBe(1); +}); + +it('turns the short way between keyframes', () => { + // 10 β†’ 90 is +80; 90 β†’ 350 is -100, not +260. + expect(pathPositionAt(square, 11_500)?.rotation).toBeCloseTo(50); + expect(pathPositionAt(square, 13_000)?.rotation).toBeCloseTo(40); +}); + +it('holds the ends without loop and wraps laps with it', () => { + expect(pathPositionAt(square, 0)?.coordinate).toEqual(square.coordinates[0]); + expect(pathPositionAt(square, 99_000)).toMatchObject({ + coordinate: square.coordinates[3], + index: 3, + }); + const looping = { ...square, loop: true }; + expect(pathPositionAt(looping, 10_500 + 4000 * 3)?.coordinate).toEqual({ + latitude: 0.5, + longitude: 0, + }); + expect(pathPositionAt(looping, 10_500 - 4000)?.coordinate).toEqual({ + latitude: 0.5, + longitude: 0, + }); +}); + +it('needs two keyframes with a time each', () => { + expect(pathPositionAt({ ...square, times: [0, 1000] }, 10_500)).toBeNull(); + expect( + pathPositionAt( + { coordinates: [square.coordinates[0]!], times: [0], startTime: 0 }, + 0 + ) + ).toBeNull(); +}); diff --git a/src/components/Marker.tsx b/src/components/Marker.tsx index c55396b..46e8213 100644 --- a/src/components/Marker.tsx +++ b/src/components/Marker.tsx @@ -13,6 +13,7 @@ export type { MarkerProps, MarkerPressEvent, MarkerDragEvent, + MarkerPath, } from './Marker.types'; const DEFAULT_ANCHOR: Point = { x: 0.5, y: 1 }; @@ -60,6 +61,7 @@ export class Marker rasterize = true, centerOnPress = true, draggable = false, + path, onPress, onDragStart, onDragChange, @@ -91,6 +93,11 @@ export class Marker rasterize={rasterize} centerOnPress={centerOnPress} draggable={draggable} + pathCoordinates={path?.coordinates} + pathTimes={path?.times} + pathRotations={path?.rotations} + pathStartTime={path?.startTime} + pathLoop={path?.loop} onMarkerPress={onPress} onMarkerDragStart={onDragStart} onMarkerDragChange={onDragChange} diff --git a/src/components/Marker.types.ts b/src/components/Marker.types.ts index 51e2c4d..ec6ba3f 100644 --- a/src/components/Marker.types.ts +++ b/src/components/Marker.types.ts @@ -23,6 +23,38 @@ export interface CalloutOptions { offset?: Point; } +/** + * A timed route that a marker follows natively, with no React renders or prop updates per frame. + */ +export interface MarkerPath { + /** + * Keyframe positions. The marker moves in a straight line between consecutive keyframes. + */ + coordinates: Coordinate[]; + /** + * Milliseconds from the start of the path for each keyframe, in increasing order. + * Same length as `coordinates`. + */ + times: number[]; + /** + * Rotation for each keyframe, in degrees clockwise from north. Each segment + * turns the short way. Without it, the marker keeps `rotate`. + */ + rotations?: number[]; + /** + * Wall-clock time in milliseconds, as from `Date.now()`, at which the marker is at the first + * keyframe. + */ + startTime: number; + /** + * Repeat the path. A lap runs from the first time to the last. + * Without it, the marker waits at the first keyframe until `startTime` and stays at the last + * one after the path ends. + * @default false + */ + loop?: boolean; +} + export interface MarkerRef { showCallout(): void; hideCallout(): void; @@ -85,6 +117,12 @@ export interface MarkerProps { * @default false */ draggable?: boolean; + /** + * Moves the marker along a timed path natively, so a moving marker costs no React work per + * frame. While set, `coordinate` is ignored, and so is `rotate` when the path has `rotations`. + * Pass the same object across renders: a new one is sent to native again. + */ + path?: MarkerPath; /** * Called when the marker is pressed */ diff --git a/src/components/Marker.web.tsx b/src/components/Marker.web.tsx index c21c03b..becbebf 100644 --- a/src/components/Marker.web.tsx +++ b/src/components/Marker.web.tsx @@ -18,8 +18,9 @@ import { useAdvancedMarkerRef, } from '@vis.gl/react-google-maps'; import { useMapContext } from '../MapProvider.web'; -import type { Point } from '../types'; -import type { MarkerProps, MarkerRef } from './Marker.types'; +import type { Coordinate, Point } from '../types'; +import type { MarkerPath, MarkerProps, MarkerRef } from './Marker.types'; +import { pathPositionAt } from './markerPath'; const CALLOUT_ARROW_HEIGHT = 12; const UNBUBBLED_CLASS = 'rnm-callout-unbubbled'; @@ -74,6 +75,47 @@ const createEvent = ( }, } as any); +const followsPath = (path: MarkerPath | undefined): path is MarkerPath => + !!path && + path.coordinates.length >= 2 && + path.times.length === path.coordinates.length; + +/** + * Moves a marker along `path` on every animation frame, calling `apply` only + * when its position or rotation changes. + */ +function usePathAnimation( + path: MarkerPath | undefined, + apply: (coordinate: Coordinate, rotation?: number) => void +) { + const { coordinates, times, rotations, startTime = 0, loop } = path ?? {}; + useEffect(() => { + if (!coordinates || !times) return; + const current = { coordinates, times, rotations, startTime, loop }; + if (!followsPath(current)) return; + let frame = 0; + let last = { + latitude: NaN, + longitude: NaN, + rotation: NaN as number | undefined, + }; + const tick = () => { + const { coordinate, rotation } = pathPositionAt(current, Date.now())!; + if ( + coordinate.latitude !== last.latitude || + coordinate.longitude !== last.longitude || + rotation !== last.rotation + ) { + last = { ...coordinate, rotation }; + apply(coordinate, rotation); + } + frame = requestAnimationFrame(tick); + }; + tick(); + return () => cancelAnimationFrame(frame); + }, [coordinates, times, rotations, startTime, loop, apply]); +} + /** * The props Reanimated's `useAnimatedProps` can drive. On web, Reanimated * applies them through `setNativeProps`, so they update without a React render. @@ -98,6 +140,7 @@ const DefaultMarker = forwardRef( scale, centerOnPress = true, draggable, + path, onPress, onDragStart, onDragChange, @@ -109,6 +152,8 @@ const DefaultMarker = forwardRef( ref ) => { const { moveCamera, onCalloutClose, closeCallouts } = useMapContext(); + const pathRef = useRef(path); + pathRef.current = path; const dragPositionRef = useRef(null); const [markerRef, markerElement] = useAdvancedMarkerRef(); const markerElementRef = useRef(markerElement); @@ -149,13 +194,41 @@ const DefaultMarker = forwardRef( } }, []); + // Reanimated's updates. A path overrides the position, and the rotation + // when it has rotations. + const setNativeProps = useCallback( + (props: AnimatableProps) => { + const following = pathRef.current; + if (!followsPath(following)) return applyNativeProps(props); + const rest = { ...props }; + delete rest.coordinate; + if (following.rotations) delete rest.rotate; + return applyNativeProps(rest); + }, + [applyNativeProps] + ); + + const applyPath = useCallback( + (at: Coordinate, rotation?: number) => + applyNativeProps( + rotation === undefined + ? { coordinate: at } + : { coordinate: at, rotate: rotation } + ), + [applyNativeProps] + ); + usePathAnimation(path, applyPath); + // Props apply when their values change. Reanimated passes its first values // again on every render, so a re-render must not pull a moving marker back. + // A path overrides them, like Reanimated's updates. const { latitude, longitude } = coordinate; useEffect(() => { + if (followsPath(pathRef.current)) return; latest.current.coordinate = { latitude, longitude }; }, [latitude, longitude]); useEffect(() => { + if (pathRef.current?.rotations && followsPath(pathRef.current)) return; latest.current.rotate = rotate; }, [rotate]); useEffect(() => { @@ -196,9 +269,9 @@ const DefaultMarker = forwardRef( hideCallout() { setInfoWindowOpen(false); }, - setNativeProps: applyNativeProps, + setNativeProps, }), - [hasCallout, closeCallouts, closeCallout, applyNativeProps] + [hasCallout, closeCallouts, closeCallout, setNativeProps] ); const calloutContent = callout @@ -355,12 +428,15 @@ const LiveMarker = forwardRef( rotate, scale, centerOnPress = true, + path, onPress, children, }, ref ) => { const { map, moveCamera } = useMapContext(); + const pathRef = useRef(path); + pathRef.current = path; const [element] = useState(createLiveElement); const overlayRef = useRef(null); const onPressRef = useRef(onPress); @@ -407,6 +483,16 @@ const LiveMarker = forwardRef( onPressRef.current = onPress; }, [onPress]); + const applyPath = useCallback( + (at: Coordinate, rotation?: number) => { + latest.current.coordinate = at; + if (rotation !== undefined) latest.current.rotate = rotation; + place(); + }, + [place] + ); + usePathAnimation(path, applyPath); + useEffect(() => { const handleClick = (event: MouseEvent) => { const at = latest.current.coordinate; @@ -424,12 +510,15 @@ const LiveMarker = forwardRef( // Props apply when their values change. Reanimated passes its first values // again on every render, so a re-render must not pull a moving marker back. + // A path overrides them, like Reanimated's updates. const { latitude, longitude } = coordinate; useLayoutEffect(() => { + if (followsPath(pathRef.current)) return; latest.current.coordinate = { latitude, longitude }; place(); }, [latitude, longitude, place]); useLayoutEffect(() => { + if (pathRef.current?.rotations && followsPath(pathRef.current)) return; latest.current.rotate = rotate ?? 0; place(); }, [rotate, place]); @@ -455,8 +544,14 @@ const LiveMarker = forwardRef( hideCallout() {}, setNativeProps(props) { const current = latest.current; - if (props.coordinate) current.coordinate = props.coordinate; - if (props.rotate !== undefined) current.rotate = props.rotate; + const following = pathRef.current; + const onPath = followsPath(following); + if (props.coordinate && !onPath) { + current.coordinate = props.coordinate; + } + if (props.rotate !== undefined && !(onPath && following.rotations)) { + current.rotate = props.rotate; + } if (props.scale !== undefined) current.scale = props.scale; if (props.zIndex !== undefined) { element.style.zIndex = String(props.zIndex); diff --git a/src/components/index.ts b/src/components/index.ts index b0ef196..456d1f1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,10 +1,13 @@ export { Marker } from './Marker'; +export { pathPositionAt } from './markerPath'; +export type { MarkerPathPosition } from './markerPath'; export type { CalloutOptions, MarkerRef, MarkerProps, MarkerPressEvent, MarkerDragEvent, + MarkerPath, } from './Marker.types'; export { Polygon } from './Polygon'; export type { PolygonProps } from './Polygon.types'; diff --git a/src/components/index.web.ts b/src/components/index.web.ts index 22971f8..140e657 100644 --- a/src/components/index.web.ts +++ b/src/components/index.web.ts @@ -1,8 +1,10 @@ export { Marker } from './Marker.web'; +export { pathPositionAt } from './markerPath'; +export type { MarkerPathPosition } from './markerPath'; export { Polygon } from './Polygon.web'; export { Circle } from './Circle.web'; export { Polyline } from './Polyline.web'; -export type { CalloutOptions, MarkerProps } from './Marker.types'; +export type { CalloutOptions, MarkerProps, MarkerPath } from './Marker.types'; export type { PolygonProps } from './Polygon.types'; export type { CircleProps } from './Circle.types'; export type { PolylineProps } from './Polyline.types'; diff --git a/src/components/markerPath.ts b/src/components/markerPath.ts new file mode 100644 index 0000000..3fa57d8 --- /dev/null +++ b/src/components/markerPath.ts @@ -0,0 +1,73 @@ +import type { Coordinate } from '../types'; +import type { MarkerPath } from './Marker.types'; + +export interface MarkerPathPosition { + coordinate: Coordinate; + /** + * Degrees clockwise from north, in [0, 360). Only when the path has + * `rotations`. + */ + rotation?: number; + /** + * Index of the last keyframe passed, within the current lap. + */ + index: number; +} + +/** + * Degrees to turn from `from` to `to` the short way, in [-180, 180). + */ +const shortTurn = (from: number, to: number) => + ((((to - from) % 360) + 540) % 360) - 180; + +/** + * Where a marker following `path` is at `time` (milliseconds, as from + * `Date.now()`), by the same rules the native code uses: straight lines + * between keyframes, and each turn the short way. Returns `null` unless the + * path has at least two keyframes and a time for each. + */ +export function pathPositionAt( + path: MarkerPath, + time: number +): MarkerPathPosition | null { + const { coordinates, times, rotations, startTime, loop } = path; + const count = coordinates.length; + if (count < 2 || times.length !== count) return null; + + const first = times[0]!; + const duration = times[count - 1]! - first; + const elapsed = time - startTime; + const progress = + loop && duration > 0 + ? ((elapsed % duration) + duration) % duration + : Math.min(Math.max(elapsed, 0), duration); + const t = first + progress; + + let lo = 0; + let hi = count - 1; + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + if (times[mid]! <= t) lo = mid; + else hi = mid; + } + const span = times[hi]! - times[lo]!; + const f = span > 0 ? (t - times[lo]!) / span : 0; + const a = coordinates[lo]!; + const b = coordinates[hi]!; + + let rotation: number | undefined; + if (rotations?.length === count) { + const from = rotations[lo]!; + const turned = from + shortTurn(from, rotations[hi]!) * f; + rotation = ((turned % 360) + 360) % 360; + } + + return { + coordinate: { + latitude: a.latitude + (b.latitude - a.latitude) * f, + longitude: a.longitude + (b.longitude - a.longitude) * f, + }, + rotation, + index: f >= 1 ? hi : lo, + }; +} diff --git a/src/fabric/LuggMapViewNativeComponent.ts b/src/fabric/LuggMapViewNativeComponent.ts index 2db1ca8..1d0b1bd 100644 --- a/src/fabric/LuggMapViewNativeComponent.ts +++ b/src/fabric/LuggMapViewNativeComponent.ts @@ -84,6 +84,10 @@ export interface NativeProps extends ViewProps { poiFilterCategories?: ReadonlyArray; theme?: CodegenTypes.WithDefault<'system' | 'light' | 'dark', 'system'>; insetAdjustment?: CodegenTypes.WithDefault<'never' | 'automatic', 'never'>; + idleFrameRate?: CodegenTypes.WithDefault< + 'maximum' | 'conservative' | 'powerSave', + 'maximum' + >; onMapPress?: CodegenTypes.DirectEventHandler; onMapLongPress?: CodegenTypes.DirectEventHandler; onCameraMove?: CodegenTypes.DirectEventHandler; diff --git a/src/fabric/LuggMarkerViewNativeComponent.ts b/src/fabric/LuggMarkerViewNativeComponent.ts index 6f1ad61..4e404aa 100644 --- a/src/fabric/LuggMarkerViewNativeComponent.ts +++ b/src/fabric/LuggMarkerViewNativeComponent.ts @@ -22,6 +22,11 @@ export interface NativeProps extends ViewProps { rasterize?: CodegenTypes.WithDefault; centerOnPress?: CodegenTypes.WithDefault; draggable?: CodegenTypes.WithDefault; + pathCoordinates?: ReadonlyArray; + pathTimes?: ReadonlyArray; + pathRotations?: ReadonlyArray; + pathStartTime?: CodegenTypes.Double; + pathLoop?: CodegenTypes.WithDefault; onMarkerPress?: CodegenTypes.DirectEventHandler<{ coordinate: { latitude: CodegenTypes.Double; diff --git a/src/index.ts b/src/index.ts index 77fa9cf..a3311ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,15 @@ export { MapView } from './MapView'; export { MapProvider } from './MapProvider'; export type { MapProviderProps } from './MapProvider.types'; -export { Marker } from './components'; +export { Marker, pathPositionAt } from './components'; export type { CalloutOptions, MarkerRef, MarkerProps, MarkerPressEvent, MarkerDragEvent, + MarkerPath, + MarkerPathPosition, } from './components'; export { Polygon } from './components'; export type { PolygonProps } from './components'; @@ -36,6 +38,7 @@ export type { MapPressEvent, MapType, MapTheme, + MapIdleFrameRate, InsetAdjustment, PoiCategory, PoiFilter, diff --git a/src/index.web.ts b/src/index.web.ts index 6307609..905c429 100644 --- a/src/index.web.ts +++ b/src/index.web.ts @@ -1,8 +1,13 @@ export { MapView } from './MapView.web'; export { MapProvider } from './MapProvider.web'; export type { MapProviderProps } from './MapProvider.types'; -export { Marker } from './components/index.web'; -export type { CalloutOptions, MarkerProps } from './components/index.web'; +export { Marker, pathPositionAt } from './components/index.web'; +export type { + CalloutOptions, + MarkerProps, + MarkerPath, + MarkerPathPosition, +} from './components/index.web'; export { Polygon } from './components/index.web'; export type { PolygonProps } from './components/index.web'; export { Circle } from './components/index.web'; From ca01ccf578faff4e5bf0770adb044bc5b4eb60af Mon Sep 17 00:00:00 2001 From: Eric Kreutzer Date: Mon, 9 Nov 2026 14:39:10 -0700 Subject: [PATCH 2/4] feat(example): drive the crew marker with a path and add fleet and tracking demos --- example/shared/src/components/CrewMarker.tsx | 159 ++++++++++++++++-- example/shared/src/components/Map.tsx | 37 +++- .../shared/src/components/TrackedMarker.tsx | 90 ++++++++++ example/shared/src/components/index.ts | 2 + example/shared/src/screens/HomeScreen.tsx | 34 ++++ example/shared/src/sheets/ControlSheet.tsx | 48 +++++- 6 files changed, 352 insertions(+), 18 deletions(-) create mode 100644 example/shared/src/components/TrackedMarker.tsx diff --git a/example/shared/src/components/CrewMarker.tsx b/example/shared/src/components/CrewMarker.tsx index ee119ba..5b94dff 100644 --- a/example/shared/src/components/CrewMarker.tsx +++ b/example/shared/src/components/CrewMarker.tsx @@ -1,6 +1,11 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { Platform } from 'react-native'; -import { Marker, type Coordinate } from '@lugg/maps'; +import { + Marker, + pathPositionAt, + type Coordinate, + type MarkerPath, +} from '@lugg/maps'; import Animated, { Easing, useAnimatedProps, @@ -16,12 +21,23 @@ const AnimatedMarker = Animated.createAnimatedComponent(Marker); const DEFAULT_ANCHOR = { x: 0.5, y: 0.5 }; const SEGMENT_DURATION = 2000; +// How long the truck takes to turn onto the next segment +const TURN_DURATION = 300; + +/** + * `path` hands the route to native code as keyframes. `reanimated` animates + * the marker from Reanimated every frame, the way apps do without `path`. + */ +export type CrewMotion = 'path' | 'reanimated'; interface CrewMarkerProps { route: Coordinate[]; loaded?: boolean; speed?: number; zoom?: number; + motion?: CrewMotion; + /** Fraction of the route to start at, so a fleet can spread out */ + phase?: number; onSegment?: (index: number) => void; } @@ -54,27 +70,137 @@ const getScaleForZoom = (zoom: number) => { return MIN_SCALE + t * (MAX_SCALE - MIN_SCALE); }; -export const CrewMarker = ({ +const useZoomScale = (zoom: number) => { + const scale = useSharedValue(getScaleForZoom(zoom)); + useEffect(() => { + scale.value = withTiming(getScaleForZoom(zoom), { duration: 200 }); + }, [zoom, scale]); + return scale; +}; + +// Two keyframes per segment: the start, and a point TURN_DURATION in where the +// truck has turned onto the segment's bearing. Native code interpolates the +// rest, so JS only runs for `onSegment`, once per segment. +const createCrewPath = ( + route: Coordinate[], + speed: number, + phase: number +): MarkerPath | null => { + if (route.length < 2) return null; + + const segment = SEGMENT_DURATION / speed; + const turn = Math.min(TURN_DURATION, segment / 2) / segment; + const coordinates: Coordinate[] = []; + const times: number[] = []; + const rotations: number[] = []; + let bearing = getRhumbLineBearing(route[0]!, route[1]!); + + for (let i = 0; i < route.length - 1; i++) { + const from = route[i]!; + const to = route[i + 1]!; + const next = getRhumbLineBearing(from, to); + coordinates.push(from, { + latitude: from.latitude + (to.latitude - from.latitude) * turn, + longitude: from.longitude + (to.longitude - from.longitude) * turn, + }); + times.push(i * segment, (i + turn) * segment); + rotations.push(bearing, next); + bearing = next; + } + coordinates.push(route[route.length - 1]!); + times.push((route.length - 1) * segment); + rotations.push(bearing); + + const lap = times[times.length - 1]!; + return { + coordinates, + times, + rotations, + startTime: Date.now() - phase * lap, + loop: true, + }; +}; + +export const CrewMarker = ({ motion = 'path', ...props }: CrewMarkerProps) => + motion === 'path' ? ( + + ) : ( + + ); + +const PathCrewMarker = ({ route, loaded = false, speed = 1, zoom = BASE_ZOOM, + phase = 0, onSegment, -}: CrewMarkerProps) => { +}: Omit) => { + const onSegmentRef = useRef(onSegment); + onSegmentRef.current = onSegment; + const scaleValue = useZoomScale(zoom); + const path = useMemo( + () => createCrewPath(route, speed, phase), + [route, speed, phase] + ); + + const animatedProps = useAnimatedProps(() => ({ + scale: scaleValue.value, + })); + + // The path is a function of the clock, so a timer at each segment boundary + // is enough to report progress + const hasOnSegment = !!onSegment; + useEffect(() => { + if (!path || !hasOnSegment) return; + const segment = SEGMENT_DURATION / speed; + let timer: ReturnType; + const tick = () => { + const now = Date.now(); + const position = pathPositionAt(path, now); + if (position) onSegmentRef.current?.(Math.floor(position.index / 2)); + timer = setTimeout( + tick, + segment - ((now - path.startTime) % segment) + 1 + ); + }; + tick(); + return () => clearTimeout(timer); + }, [path, speed, hasOnSegment]); + + if (!path || !route[0]) return null; + + return ( + + + + ); +}; + +const ReanimatedCrewMarker = ({ + route, + loaded = false, + speed = 1, + zoom = BASE_ZOOM, + phase = 0, + onSegment, +}: Omit) => { const onSegmentRef = useRef(onSegment); onSegmentRef.current = onSegment; const latitude = useSharedValue(route[0]?.latitude ?? 0); const longitude = useSharedValue(route[0]?.longitude ?? 0); const bearingValue = useSharedValue(0); - const scaleValue = useSharedValue(getScaleForZoom(zoom)); + const scaleValue = useZoomScale(zoom); const currentBearingRef = useRef(0); const segmentIndexRef = useRef(0); const animationRef = useRef(null); - useEffect(() => { - scaleValue.value = withTiming(getScaleForZoom(zoom), { duration: 200 }); - }, [zoom, scaleValue]); - const zIndex = useDerivedValue(() => { return Math.round((90 - latitude.value) * 10000); }); @@ -95,10 +221,13 @@ export const CrewMarker = ({ useEffect(() => { if (route.length < 2) return; - // Reset to start - segmentIndexRef.current = 0; - latitude.value = route[0]!.latitude; - longitude.value = route[0]!.longitude; + const startIndex = Math.min( + route.length - 2, + Math.floor(phase * (route.length - 1)) + ); + segmentIndexRef.current = startIndex; + latitude.value = route[startIndex]!.latitude; + longitude.value = route[startIndex]!.longitude; const animateSegment = (index: number) => { if (index >= route.length - 1) { @@ -130,14 +259,14 @@ export const CrewMarker = ({ }, duration); }; - animateSegment(0); + animateSegment(startIndex); return () => { if (animationRef.current) { clearTimeout(animationRef.current); } }; - }, [route, speed, bearingValue, latitude, longitude]); + }, [route, speed, phase, bearingValue, latitude, longitude]); if (!route[0]) return null; diff --git a/example/shared/src/components/Map.tsx b/example/shared/src/components/Map.tsx index 856ce63..b2909ac 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -34,7 +34,8 @@ import Animated, { type SharedValue, } from 'react-native-reanimated'; -import { CrewMarker } from './CrewMarker'; +import { CrewMarker, type CrewMotion } from './CrewMarker'; +import { TrackedMarker } from './TrackedMarker'; import { MarkerIcon } from './MarkerIcon'; import { MarkerText } from './MarkerText'; import { MarkerImage } from './MarkerImage'; @@ -55,6 +56,11 @@ interface MapProps extends MapViewProps { markers: MarkerData[]; geojson?: GeoJSON | null; animatedPosition?: SharedValue; + crewMotion?: CrewMotion; + /** Extra trucks on copies of the route, to compare motion at scale */ + fleetSize?: number; + /** Show a marker that follows simulated GPS fixes */ + tracking?: boolean; onPolygonPress?: () => void; onCirclePress?: () => void; onGroundOverlayPress?: () => void; @@ -263,6 +269,9 @@ export const Map = memo( provider, edgeInsets, animatedPosition, + crewMotion = 'path', + fleetSize = 0, + tracking = false, onCameraIdle, onCameraMove, onPress, @@ -311,6 +320,20 @@ export const Map = memo( [markers] ); + // Copies of the route spread over the map, so the fleet doesn't share one road + const fleetRoutes = useMemo( + () => + Array.from({ length: fleetSize }, (_, i) => { + const dLat = (((i * 7) % 11) - 5) * 0.002; + const dLng = (((i * 5) % 13) - 6) * 0.002; + return smoothedRoute.map((c) => ({ + latitude: c.latitude + dLat, + longitude: c.longitude + dLng, + })); + }), + [smoothedRoute, fleetSize] + ); + const [truckIndex, setTruckIndex] = useState(0); const routeAhead = useMemo( () => smoothedRoute.slice(truckIndex), @@ -364,8 +387,20 @@ export const Map = memo( + {fleetRoutes.map((route, i) => ( + + ))} + {tracking && } { + // Where the vehicle really is, sampled with noise to simulate fixes + const actual = useMemo( + () => + route.length < 2 + ? null + : { + coordinates: route, + times: route.map((_, i) => i * SEGMENT_DURATION), + startTime: Date.now(), + loop: true, + }, + [route] + ); + const [path, setPath] = useState(null); + + useEffect(() => { + if (!actual) return; + let current: MarkerPath | null = null; + + const receiveFix = () => { + const now = Date.now(); + const sample = pathPositionAt(actual, now)!.coordinate; + const fix = { + latitude: sample.latitude + (Math.random() - 0.5) * NOISE_DEGREES, + longitude: sample.longitude + (Math.random() - 0.5) * NOISE_DEGREES, + }; + const from = current ? pathPositionAt(current, now) : null; + // A fix reports the course too, so turn to it rather than toward the + // noisy fix + const course = getRhumbLineBearing( + sample, + pathPositionAt(actual, now + 1000)!.coordinate + ); + current = { + coordinates: [from?.coordinate ?? fix, fix], + times: [0, FIX_INTERVAL], + rotations: [from?.rotation ?? course, course], + startTime: now, + }; + setPath(current); + }; + + receiveFix(); + const interval = setInterval(receiveFix, FIX_INTERVAL); + return () => clearInterval(interval); + }, [actual]); + + if (!path) return null; + + return ( + + + + ); +}; diff --git a/example/shared/src/components/index.ts b/example/shared/src/components/index.ts index 85ba377..f663693 100644 --- a/example/shared/src/components/index.ts +++ b/example/shared/src/components/index.ts @@ -6,6 +6,8 @@ export { MarkerIcon } from './MarkerIcon'; export { MarkerText } from './MarkerText'; export { MarkerImage } from './MarkerImage'; export { CrewMarker } from './CrewMarker'; +export type { CrewMotion } from './CrewMarker'; +export { TrackedMarker } from './TrackedMarker'; export { PickupIcon } from './PickupIcon'; export { MapTypeButton } from './MapTypeButton'; diff --git a/example/shared/src/screens/HomeScreen.tsx b/example/shared/src/screens/HomeScreen.tsx index 5fb5608..f1bf06e 100644 --- a/example/shared/src/screens/HomeScreen.tsx +++ b/example/shared/src/screens/HomeScreen.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useCallback } from 'react'; import { StyleSheet, View, useWindowDimensions } from 'react-native'; import { MapProvider, + type MapIdleFrameRate, type MapProviderType, type MapType, type MapCameraEvent, @@ -18,6 +19,7 @@ import { ReanimatedTrueSheetProvider } from '@lodev09/react-native-true-sheet/re import { Map, + type CrewMotion, type MapRef, type MarkerData, MapTypeButton, @@ -45,6 +47,13 @@ interface HomeProps { onShowStaticMaps?: () => void; } +const FLEET_SIZES = [0, 10, 50]; +const IDLE_FRAME_RATES: MapIdleFrameRate[] = [ + 'maximum', + 'conservative', + 'powerSave', +]; + export const HomeScreen = ({ onMarkerPress, onShowStaticMaps }: HomeProps) => { const apiKey = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY; @@ -80,6 +89,11 @@ const HomeContent = ({ const [showMap, setShowMap] = useState(true); const [status, setStatus] = useState({ text: 'Loading...', error: false }); const [geojson, setGeojson] = useState(null); + const [fleetSize, setFleetSize] = useState(0); + const [crewMotion, setCrewMotion] = useState('path'); + const [idleFrameRate, setIdleFrameRate] = + useState('maximum'); + const [tracking, setTracking] = useState(false); const statusLockRef = useRef(false); const lockStatus = useCallback(() => { @@ -250,6 +264,10 @@ const HomeContent = ({ markers={markers} geojson={geojson} animatedPosition={controlSheetRef.current?.animatedPosition} + crewMotion={crewMotion} + fleetSize={fleetSize} + tracking={tracking} + idleFrameRate={idleFrameRate} userLocationEnabled={locationPermission} compassEnabled={false} onReady={handleMapReady} @@ -276,6 +294,10 @@ const HomeContent = ({ showMap={showMap} provider={provider} hasGeojson={!!geojson} + fleetSize={fleetSize} + crewMotion={crewMotion} + idleFrameRate={idleFrameRate} + tracking={tracking} onAddMarker={() => addMarker()} onRemoveMarker={removeRandom} onClearMarkers={clear} @@ -288,6 +310,18 @@ const HomeContent = ({ } onLoadGeojson={() => geojsonSheetRef.current?.present()} onShowStaticMaps={onShowStaticMaps} + onCycleFleet={() => + setFleetSize((n) => FLEET_SIZES[(FLEET_SIZES.indexOf(n) + 1) % 3]!) + } + onToggleMotion={() => + setCrewMotion((m) => (m === 'path' ? 'reanimated' : 'path')) + } + onCycleIdleFrameRate={() => + setIdleFrameRate( + (r) => IDLE_FRAME_RATES[(IDLE_FRAME_RATES.indexOf(r) + 1) % 3]! + ) + } + onToggleTracking={() => setTracking((t) => !t)} onDidPresent={handleSheetEvent} onDetentChange={handleSheetEvent} /> diff --git a/example/shared/src/sheets/ControlSheet.tsx b/example/shared/src/sheets/ControlSheet.tsx index a80ef53..f5ba852 100644 --- a/example/shared/src/sheets/ControlSheet.tsx +++ b/example/shared/src/sheets/ControlSheet.tsx @@ -1,6 +1,6 @@ import { forwardRef, useImperativeHandle, useRef } from 'react'; import { Platform, StyleSheet, View } from 'react-native'; -import type { MapProviderType } from '@lugg/maps'; +import type { MapIdleFrameRate, MapProviderType } from '@lugg/maps'; import { ReanimatedTrueSheet, useReanimatedTrueSheet, @@ -10,7 +10,7 @@ import { type DetentChangeEvent, } from '@lodev09/react-native-true-sheet'; -import { Button, ThemedText } from '../components'; +import { Button, ThemedText, type CrewMotion } from '../components'; import { sizes, useTheme } from '../theme'; interface StatusInfo { @@ -24,6 +24,10 @@ interface ControlSheetProps { showMap: boolean; provider: MapProviderType; hasGeojson: boolean; + fleetSize: number; + crewMotion: CrewMotion; + idleFrameRate: MapIdleFrameRate; + tracking: boolean; onAddMarker: () => void; onRemoveMarker: () => void; onClearMarkers: () => void; @@ -34,6 +38,10 @@ interface ControlSheetProps { onToggleProvider: () => void; onLoadGeojson: () => void; onShowStaticMaps?: () => void; + onCycleFleet: () => void; + onToggleMotion: () => void; + onCycleIdleFrameRate: () => void; + onToggleTracking: () => void; onDidPresent?: (event: DetentChangeEvent) => void; onDetentChange?: (event: DetentChangeEvent) => void; } @@ -52,6 +60,10 @@ export const ControlSheet = forwardRef( showMap, provider, hasGeojson, + fleetSize, + crewMotion, + idleFrameRate, + tracking, onAddMarker, onRemoveMarker, onClearMarkers, @@ -62,6 +74,10 @@ export const ControlSheet = forwardRef( onToggleProvider, onLoadGeojson, onShowStaticMaps, + onCycleFleet, + onToggleMotion, + onCycleIdleFrameRate, + onToggleTracking, onDidPresent, onDetentChange, }, @@ -152,12 +168,40 @@ export const ControlSheet = forwardRef( disabled={!onShowStaticMaps} onPress={() => onShowStaticMaps?.()} /> +