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..a8c613a 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 + "power-save" -> 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,22 @@ class GoogleMapProvider(private val context: Context) : return } + markerView.updatePathPosition(System.currentTimeMillis().toDouble()) + // Each Marker setter calls into the Maps SDK, so only changed values are set markerView.marker?.apply { - if (!markerView.isDragging) { - position = LatLng(markerView.latitude, markerView.longitude) + if (!markerView.isDragging && + (position.latitude != markerView.displayLatitude || position.longitude != markerView.displayLongitude) + ) { + position = LatLng(markerView.displayLatitude, markerView.displayLongitude) } - title = markerView.title - snippet = markerView.description + if (title != markerView.title) title = markerView.title + if (snippet != markerView.description) snippet = markerView.description setAnchor(markerView.anchorX, markerView.anchorY) - zIndex = markerView.zIndex - rotation = markerView.rotate - isDraggable = markerView.draggable + if (zIndex != markerView.zIndex) zIndex = markerView.zIndex + if (rotation != markerView.displayRotation) rotation = markerView.displayRotation + if (isDraggable != markerView.draggable) isDraggable = markerView.draggable } + updatePathMarker(markerView) if (markerView.hasCustomView) { if (markerView.scaleChanged) { @@ -797,7 +822,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 +832,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 +897,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 +905,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 +1331,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 +1738,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..098bf4f 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-markers). 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 | +| `'power-save'` | 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..6753446 100644 --- a/docs/content/docs/components/marker.mdx +++ b/docs/content/docs/components/marker.mdx @@ -42,6 +42,10 @@ 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 route natively. While set, `coordinate` is ignored, and so is `rotate` when the path rotates the marker | +| `rotateAlongPath` | `boolean` | `false` | Turns the marker to face its direction of travel, along a `path` or a `transitionDuration` move | +| `transitionDuration` | `number` | `0` | Milliseconds over which the marker glides natively to a new `coordinate` and `rotate`, from where it is. `0` jumps. Ignored while `path` is set | +| `onPathProgress` | `(event: MarkerPathProgressEvent) => void` | - | Called when the marker passes a keyframe of its `path`, and once when the path starts. Event includes `index` in `coordinates` | | `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 +96,70 @@ Set `draggable` to enable marker dragging. Use the drag event callbacks to track /> ``` +## Moving Markers + +Native code moves the marker, 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. + +### Along a route + +Give a marker a `path` and a `duration`. It travels the route at a constant speed. + +```tsx +const path = useMemo( + () => ({ coordinates: route, duration: 60_000, loop: true }), + [route] +); + + setLeg(e.nativeEvent.index)} + anchor={{ x: 0.5, y: 0.5 }} +> + + +``` + +- The marker moves in a straight line between keyframes, so add keyframes through curves and corners. +- `rotateAlongPath` faces each segment and settles each turn quickly after the corner. For custom headings, pass `rotations` instead. +- For exact timing, pass `times` instead of `duration`. +- The path restarts when one of its fields changes. Arrays compare by reference, so keep `coordinates`, `times` and `rotations` stable across renders (for example with `useMemo`). An inline `path` object is fine. +- The path starts when it's set. Set `startTime` to keep several markers or devices in sync, or to start partway: `startTime: Date.now() - 0.5 * duration` starts halfway. +- Presses report where the marker is on its path, and `centerOnPress` centers there. +- To redraw the map less often while markers move and the camera is still, set the `MapView`'s [`idleFrameRate`](./map-view.mdx#mapidleframerate). + +### Live coordinates + +For positions that arrive over time, such as GPS fixes, set `transitionDuration` to the update interval. Each new `coordinate` glides the marker there from where it is, so fixes chain into continuous motion. + +```tsx + + + +``` + +- Pass `rotateAlongPath` instead of `rotate` to face each move, when fixes have no course. Moves shorter than 5 m keep the heading, so GPS drift doesn't spin a parked marker. +- It applies to `coordinate` changes from renders. Reanimated `animatedProps` updates bypass it. + +### MarkerPath + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `coordinates` | `Coordinate[]` | **required** | Keyframe positions | +| `duration` | `number` | - | Milliseconds to travel the whole path, at a constant speed. Pass this or `times` | +| `times` | `number[]` | - | Milliseconds from the start of the path for each keyframe, in increasing order. Pass this or `duration` | +| `rotations` | `number[]` | - | Degrees clockwise from north for each keyframe. Each segment turns the short way. Overrides `rotateAlongPath` | +| `startTime` | `number` | when set | 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 | + +`MarkerPath` takes either `duration` or `times`, so `path.times` is typed `number[] | undefined`. To read it back from a helper, build the path with `satisfies MarkerPath` instead of a `MarkerPath` return type. + ## 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/example/shared/src/components/CrewMarker.tsx b/example/shared/src/components/CrewMarker.tsx index ee119ba..9afae27 100644 --- a/example/shared/src/components/CrewMarker.tsx +++ b/example/shared/src/components/CrewMarker.tsx @@ -1,6 +1,6 @@ -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, type Coordinate, type MarkerPath } from '@lugg/maps'; import Animated, { Easing, useAnimatedProps, @@ -17,11 +17,20 @@ const AnimatedMarker = Animated.createAnimatedComponent(Marker); const DEFAULT_ANCHOR = { x: 0.5, y: 0.5 }; const SEGMENT_DURATION = 2000; +/** + * `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 +63,82 @@ 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; +}; + +export const CrewMarker = ({ motion = 'path', ...props }: CrewMarkerProps) => + motion === 'path' ? ( + + ) : ( + + ); + +const PathCrewMarker = ({ + route, + loaded = false, + speed = 1, + zoom = BASE_ZOOM, + phase = 0, + onSegment, +}: Omit) => { + const scaleValue = useZoomScale(zoom); + const animatedProps = useAnimatedProps(() => ({ + scale: scaleValue.value, + })); + const path = useMemo(() => { + if (route.length < 2) return null; + const duration = ((route.length - 1) * SEGMENT_DURATION) / speed; + return { + coordinates: route, + duration, + // Start partway round, so a fleet spreads out + startTime: Date.now() - phase * duration, + loop: true, + }; + }, [route, speed, phase]); + + if (!path || !route[0]) return null; + + return ( + onSegment(e.nativeEvent.index) : undefined + } + anchor={DEFAULT_ANCHOR} + rasterize={Platform.OS !== 'web'} + animatedProps={animatedProps} + > + + + ); +}; + +const ReanimatedCrewMarker = ({ route, loaded = false, speed = 1, zoom = BASE_ZOOM, + phase = 0, onSegment, -}: CrewMarkerProps) => { +}: 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 +159,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 +197,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 && } { + const progress = (elapsed / SEGMENT_DURATION) % (route.length - 1); + const index = Math.floor(progress); + const from = route[index]!; + const to = route[index + 1]!; + const f = progress - index; + return { + coordinate: { + latitude: + from.latitude + + (to.latitude - from.latitude) * f + + (Math.random() - 0.5) * NOISE_DEGREES, + longitude: + from.longitude + + (to.longitude - from.longitude) * f + + (Math.random() - 0.5) * NOISE_DEGREES, + }, + course: getRhumbLineBearing(from, to), + }; +}; + +/** + * Live tracking: each GPS fix is the marker's new `coordinate`, and + * `transitionDuration` glides it there natively until the next fix arrives. + */ +export const TrackedMarker = ({ route }: TrackedMarkerProps) => { + const [fix, setFix] = useState(null); + + useEffect(() => { + if (route.length < 2) return; + const start = Date.now(); + const receiveFix = () => setFix(sampleFix(route, Date.now() - start)); + receiveFix(); + const interval = setInterval(receiveFix, FIX_INTERVAL); + return () => clearInterval(interval); + }, [route]); + + if (!fix) 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..8244444 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', + 'power-save', +]; + 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..bf54fd8 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?.()} /> +