diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 581a3fc..45d5c0b 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -49,6 +49,7 @@ import com.luggmaps.extensions.findViewByTag import java.net.URL import kotlin.math.atan import kotlin.math.ln +import kotlin.math.log2 import kotlin.math.pow import kotlin.math.sin import kotlin.math.sinh @@ -1203,6 +1204,17 @@ class GoogleMapProvider(private val context: Context) : // region Commands override fun moveCamera(latitude: Double, longitude: Double, zoom: Double, duration: Int) { + if (staticMode) { + // Static maps don't animate; re-center at the final camera + initialLatitude = latitude + initialLongitude = longitude + if (zoom > 0) initialZoom = zoom.toFloat() + val map = googleMap ?: return + map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom)) + positionLiveMarkers() + return + } + val map = googleMap ?: return val position = LatLng(latitude, longitude) val targetZoom = if (zoom > 0) zoom.toFloat() else map.cameraPosition.zoom @@ -1232,15 +1244,20 @@ class GoogleMapProvider(private val context: Context) : val latLongs = coordinates.filterIsInstance() if (latLongs.isEmpty()) return - val boundsBuilder = LatLngBounds.Builder() - latLongs.forEach { boundsBuilder.include(it) } - val bounds = boundsBuilder.build() - val top = edgeInsetsTop.toFloat().dpToPx().toInt() val left = edgeInsetsLeft.toFloat().dpToPx().toInt() val bottom = edgeInsetsBottom.toFloat().dpToPx().toInt() val right = edgeInsetsRight.toFloat().dpToPx().toInt() + if (staticMode) { + fitStaticCoordinates(latLongs, top, left, bottom, right) + return + } + + val boundsBuilder = LatLngBounds.Builder() + latLongs.forEach { boundsBuilder.include(it) } + val bounds = boundsBuilder.build() + val combined = combinedEdgeInsets() map.setPadding( combined.left + left, @@ -1341,14 +1358,79 @@ class GoogleMapProvider(private val context: Context) : val offsetY = (edgeInsets.top - edgeInsets.bottom) / 2.0 if (offsetX == 0.0 && offsetY == 0.0) return LatLng(initialLatitude, initialLongitude) - // Web mercator world size in px at the initial zoom - val worldSize = 256f.dpToPx().toDouble() * 2.0.pow(initialZoom.toDouble()) - val sinLat = sin(Math.toRadians(initialLatitude)) - val x = (initialLongitude + 180.0) / 360.0 - offsetX / worldSize - val y = 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI) - offsetY / worldSize - return LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0) + val worldSize = mercatorWorldSize(initialZoom) + return latLngFromMercator( + mercatorX(initialLongitude) - offsetX / worldSize, + mercatorY(initialLatitude) - offsetY / worldSize + ) + } + + // A static camera can't fit with map padding (lite mode misrenders it); + // compute the fitted camera in mercator space instead, shifting the + // center for asymmetric padding like edge insets + private fun fitStaticCoordinates( + coordinates: List, + top: Int, + left: Int, + bottom: Int, + right: Int + ) { + val map = googleMap ?: return + val wrapper = wrapperView ?: return + if (wrapper.width == 0 || wrapper.height == 0) return + + var minX = Double.MAX_VALUE + var minY = Double.MAX_VALUE + var maxX = -Double.MAX_VALUE + var maxY = -Double.MAX_VALUE + for (coordinate in coordinates) { + val x = mercatorX(coordinate.longitude) + val y = mercatorY(coordinate.latitude) + minX = minOf(minX, x) + maxX = maxOf(maxX, x) + minY = minOf(minY, y) + maxY = maxOf(maxY, y) + } + + val insets = combinedEdgeInsets() + val availWidth = (wrapper.width - insets.left - insets.right - left - right).coerceAtLeast(1) + val availHeight = (wrapper.height - insets.top - insets.bottom - top - bottom).coerceAtLeast(1) + + // World size in px at which the bounds fit the padded viewport; + // infinite for coincident coordinates - keep the current zoom then + val fitWorldSize = minOf(availWidth / (maxX - minX), availHeight / (maxY - minY)) + if (fitWorldSize.isFinite()) { + initialZoom = log2(fitWorldSize / 256f.dpToPx()) + .toFloat() + .coerceIn(map.minZoomLevel, map.maxZoomLevel) + } + + val worldSize = mercatorWorldSize(initialZoom) + val center = latLngFromMercator( + (minX + maxX) / 2.0 - (left - right) / 2.0 / worldSize, + (minY + maxY) / 2.0 - (top - bottom) / 2.0 / worldSize + ) + initialLatitude = center.latitude + initialLongitude = center.longitude + + map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom)) + positionLiveMarkers() } + // Web mercator world space in [0, 1] + private fun mercatorX(longitude: Double): Double = (longitude + 180.0) / 360.0 + + private fun mercatorY(latitude: Double): Double { + val sinLat = sin(Math.toRadians(latitude)) + return 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI) + } + + private fun latLngFromMercator(x: Double, y: Double): LatLng = + LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0) + + // World size in px at the given zoom + private fun mercatorWorldSize(zoom: Float): Double = 256f.dpToPx().toDouble() * 2.0.pow(zoom.toDouble()) + private fun applyWatermarkTranslation(insets: EdgeInsets, duration: Int = 0) { val view = mapView ?: return view.findViewByTag("GoogleWatermark")?.let { watermark -> diff --git a/docs/MAPVIEW.md b/docs/MAPVIEW.md index 960e565..c1febc9 100644 --- a/docs/MAPVIEW.md +++ b/docs/MAPVIEW.md @@ -92,8 +92,10 @@ mounting many live maps is expensive: Notes: - `staticMode` is creation-time only and cannot be toggled after the map is created. -- The map renders once with its initial camera and children. Camera commands - and prop updates after the snapshot are ignored on iOS. +- Ref methods (`moveCamera`, `fitCoordinates`, `setEdgeInsets`) work like on + a live map, minus animation - `duration` is ignored and the map re-renders + at the final camera (iOS) or re-centers (Android). Map-setting prop + updates (e.g. `mapType`) after the snapshot are still ignored on iOS. - `edgeInsets` shift the visible center like on a live map, so the coordinate centers in the inset viewport. Changing insets after the render re-renders the base map (iOS) or re-centers the camera (Android). diff --git a/ios/LuggMapView.mm b/ios/LuggMapView.mm index 5a97763..e661629 100644 --- a/ios/LuggMapView.mm +++ b/ios/LuggMapView.mm @@ -248,18 +248,26 @@ - (nullable NSString *)staticSnapshotCacheKey { : -(NSInteger)_theme; // Camera is part of the key so a reused staticKey with a different - // location/zoom can't serve a stale snapshot + // location/zoom can't serve a stale snapshot. The provider's camera (when + // available) reflects imperative camera changes, so a re-rendered + // snapshot is keyed on what it actually shows. const auto &viewProps = *std::static_pointer_cast(_props); + double latitude = viewProps.initialCoordinate.latitude; + double longitude = viewProps.initialCoordinate.longitude; + double zoom = viewProps.initialZoom; + if ([_provider isKindOfClass:[StaticMapProviderBase class]]) { + StaticMapProviderBase *staticProvider = (StaticMapProviderBase *)_provider; + latitude = staticProvider.coordinate.latitude; + longitude = staticProvider.coordinate.longitude; + zoom = staticProvider.zoom; + } return [NSString stringWithFormat:@"%@|%d|%@|%d|%ld|%.0fx%.0f|%.6f,%.6f,%.2f|%@", _staticKey, (int)_providerType, _mapId, (int)_mapType, - (long)style, size.width, size.height, - viewProps.initialCoordinate.latitude, - viewProps.initialCoordinate.longitude, - viewProps.initialZoom, - NSStringFromUIEdgeInsets(_edgeInsets)]; + (long)style, size.width, size.height, latitude, + longitude, zoom, NSStringFromUIEdgeInsets(_edgeInsets)]; } - (void)startStaticContent { @@ -322,14 +330,6 @@ - (void)initializeProvider { _provider = google; } - if (_staticMode) { - NSString *cacheKey = [self staticSnapshotCacheKey]; - if (cacheKey) { - ((StaticMapProviderBase *)_provider).cachedBaseImage = - [StaticSnapshotCache() objectForKey:cacheKey]; - } - } - _provider.delegate = self; _provider.staticMode = _staticMode; @@ -341,6 +341,16 @@ - (void)initializeProvider { initialCoordinate:coordinate initialZoom:viewProps.initialZoom]; + // After initializeMapInView so the cache key reads the provider's camera; + // the base render is async and picks up the cached image + if (_staticMode) { + NSString *cacheKey = [self staticSnapshotCacheKey]; + if (cacheKey) { + ((StaticMapProviderBase *)_provider).cachedBaseImage = + [StaticSnapshotCache() objectForKey:cacheKey]; + } + } + // Apply cached props after map view is created [self applyProps]; [_provider setEdgeInsets:_edgeInsets oldEdgeInsets:UIEdgeInsetsZero]; diff --git a/ios/core/AppleStaticMapProvider.mm b/ios/core/AppleStaticMapProvider.mm index fc89761..7e88ab0 100644 --- a/ios/core/AppleStaticMapProvider.mm +++ b/ios/core/AppleStaticMapProvider.mm @@ -74,6 +74,19 @@ - (UIView *)newDefaultMarkerOverlayView { return pin; } +// Inverse of LuggStaticFittedMapRect: the pre-fit rect is square in map +// points with side latitudeDelta * worldHeight / (360 * cos(lat)), and the +// aspect fit scales it to the view's short side +- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint { + CGSize size = self.projectedSize; + double side = mapPointsPerPoint * MIN(size.width, size.height); + double latitudeDelta = side * 360.0 * + cos(self.coordinate.latitude * M_PI / 180.0) / + MKMapSizeWorld.height; + double zoom = 0.5 + log2(360.0 / latitudeDelta); + return MAX(MIN(zoom, 28.0), 0.0); +} + - (UITraitCollection *)snapshotTraitCollection { UITraitCollection *base = self.wrapperView.traitCollection; UIUserInterfaceStyle style; diff --git a/ios/core/GoogleStaticMapProvider.mm b/ios/core/GoogleStaticMapProvider.mm index 666295d..3b120ab 100644 --- a/ios/core/GoogleStaticMapProvider.mm +++ b/ios/core/GoogleStaticMapProvider.mm @@ -74,6 +74,12 @@ - (UIView *)newDefaultMarkerOverlayView { [[UIImageView alloc] initWithImage:[GMSMarker markerImageWithColor:nil]]; } +// Inverse of mapRectForSize +- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint { + double zoom = log2(MKMapSizeWorld.width / (256.0 * mapPointsPerPoint)); + return MAX(MIN(zoom, (double)kGMSMaxZoomLevel), (double)kGMSMinZoomLevel); +} + - (void)renderBaseMap { if (_warmupMapView) { // Retry a swap that couldn't run (e.g. view was off-window) diff --git a/ios/core/StaticMapProviderBase.h b/ios/core/StaticMapProviderBase.h index b3d3f82..f342a66 100644 --- a/ios/core/StaticMapProviderBase.h +++ b/ios/core/StaticMapProviderBase.h @@ -56,6 +56,11 @@ CGPoint LuggStaticPointForCoordinate(MKMapRect mapRect, CGSize size, /// Overlay view shown for markers without a custom child view. - (UIView *)newDefaultMarkerOverlayView; +/// Zoom level at which the base map renders at the given scale (map points +/// per view point) for the current coordinate and view size; the inverse of +/// mapRectForSize. Used by fitCoordinates. +- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint; + /// Starts rendering the base map for the current projection (guaranteed /// projectionReady, no cached image). Called after mount (once initial /// props have applied), after a resize, and from resumeAnimations to retry diff --git a/ios/core/StaticMapProviderBase.mm b/ios/core/StaticMapProviderBase.mm index 5022e43..711a0e4 100644 --- a/ios/core/StaticMapProviderBase.mm +++ b/ios/core/StaticMapProviderBase.mm @@ -386,6 +386,25 @@ - (void)wrapperDidLayout { [self renderBaseMapIfNeeded]; } +// Camera commands invalidate any cached, displayed or in-flight base +// render and re-render at the new camera +- (void)rerenderBaseMap { + [self cancelBaseRender]; + _baseRenderDone = NO; + self.cachedBaseImage = nil; + + // Without a layout yet, the first layout renders with the new camera + if (!_projectionReady) + return; + + [self updateProjection]; + + __weak StaticMapProviderBase *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf renderBaseMapIfNeeded]; + }); +} + - (void)renderBaseMapIfNeeded { if (_baseRenderDone || !_projectionReady || !_wrapperView) return; @@ -436,6 +455,10 @@ - (UIView *)newDefaultMarkerOverlayView { return [[UIView alloc] init]; } +- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint { + return _zoom; +} + - (void)renderBaseMap { } @@ -758,12 +781,17 @@ - (void)resumeAnimations { #pragma mark - Commands -// Static maps render once with their initial camera +// Static maps don't animate; commands re-render once at the final camera - (void)moveCamera:(double)latitude longitude:(double)longitude zoom:(double)zoom duration:(double)duration { + _coordinate = CLLocationCoordinate2DMake(latitude, longitude); + if (zoom > 0) { + _zoom = zoom; + } + [self rerenderBaseMap]; } - (void)fitCoordinates:(NSArray *)coordinates @@ -772,6 +800,46 @@ - (void)fitCoordinates:(NSArray *)coordinates edgeInsetsBottom:(double)edgeInsetsBottom edgeInsetsRight:(double)edgeInsetsRight duration:(double)duration { + if (coordinates.count == 0 || !_projectionReady) + return; + + MKMapRect boundsRect = MKMapRectNull; + for (NSDictionary *coordinate in coordinates) { + MKMapPoint point = MKMapPointForCoordinate( + CLLocationCoordinate2DMake([coordinate[@"latitude"] doubleValue], + [coordinate[@"longitude"] doubleValue])); + boundsRect = + MKMapRectUnion(boundsRect, MKMapRectMake(point.x, point.y, 0, 0)); + } + + double availWidth = + MAX(_projectedSize.width - _edgeInsets.left - _edgeInsets.right - + edgeInsetsLeft - edgeInsetsRight, + 1.0); + double availHeight = + MAX(_projectedSize.height - _edgeInsets.top - _edgeInsets.bottom - + edgeInsetsTop - edgeInsetsBottom, + 1.0); + + // Map points per view point at which the bounds fit the padded viewport + double scale = MAX(boundsRect.size.width / availWidth, + boundsRect.size.height / availHeight); + MKMapPoint center = MKMapPointMake(MKMapRectGetMidX(boundsRect), + MKMapRectGetMidY(boundsRect)); + if (scale > 0) { + _coordinate = MKCoordinateForMapPoint(center); + _zoom = [self zoomForMapPointsPerPoint:scale]; + } else { + // Coincident coordinates; keep the current zoom + scale = _mapRect.size.width / _projectedSize.width; + } + + // Asymmetric padding shifts the visible center, like edge insets + center.x -= (edgeInsetsLeft - edgeInsetsRight) / 2.0 * scale; + center.y -= (edgeInsetsTop - edgeInsetsBottom) / 2.0 * scale; + _coordinate = MKCoordinateForMapPoint(center); + + [self rerenderBaseMap]; } @end