Skip to content

Commit dd5772e

Browse files
authored
feat: support camera commands on static maps (#87)
moveCamera and fitCoordinates now work in static mode. iOS re-renders the base map at the final camera (fitCoordinates derives zoom via a provider-specific inverse of the projection); Android re-centers through the static camera-target shift since lite mode misrenders padding. The iOS snapshot cache keys on the provider's actual camera so command-driven re-renders don't poison initial-camera entries. duration is ignored - static maps don't animate.
1 parent 5ee32c8 commit dd5772e

7 files changed

Lines changed: 213 additions & 27 deletions

File tree

android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import com.luggmaps.extensions.findViewByTag
4949
import java.net.URL
5050
import kotlin.math.atan
5151
import kotlin.math.ln
52+
import kotlin.math.log2
5253
import kotlin.math.pow
5354
import kotlin.math.sin
5455
import kotlin.math.sinh
@@ -1203,6 +1204,17 @@ class GoogleMapProvider(private val context: Context) :
12031204
// region Commands
12041205

12051206
override fun moveCamera(latitude: Double, longitude: Double, zoom: Double, duration: Int) {
1207+
if (staticMode) {
1208+
// Static maps don't animate; re-center at the final camera
1209+
initialLatitude = latitude
1210+
initialLongitude = longitude
1211+
if (zoom > 0) initialZoom = zoom.toFloat()
1212+
val map = googleMap ?: return
1213+
map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1214+
positionLiveMarkers()
1215+
return
1216+
}
1217+
12061218
val map = googleMap ?: return
12071219
val position = LatLng(latitude, longitude)
12081220
val targetZoom = if (zoom > 0) zoom.toFloat() else map.cameraPosition.zoom
@@ -1232,15 +1244,20 @@ class GoogleMapProvider(private val context: Context) :
12321244
val latLongs = coordinates.filterIsInstance<LatLng>()
12331245
if (latLongs.isEmpty()) return
12341246

1235-
val boundsBuilder = LatLngBounds.Builder()
1236-
latLongs.forEach { boundsBuilder.include(it) }
1237-
val bounds = boundsBuilder.build()
1238-
12391247
val top = edgeInsetsTop.toFloat().dpToPx().toInt()
12401248
val left = edgeInsetsLeft.toFloat().dpToPx().toInt()
12411249
val bottom = edgeInsetsBottom.toFloat().dpToPx().toInt()
12421250
val right = edgeInsetsRight.toFloat().dpToPx().toInt()
12431251

1252+
if (staticMode) {
1253+
fitStaticCoordinates(latLongs, top, left, bottom, right)
1254+
return
1255+
}
1256+
1257+
val boundsBuilder = LatLngBounds.Builder()
1258+
latLongs.forEach { boundsBuilder.include(it) }
1259+
val bounds = boundsBuilder.build()
1260+
12441261
val combined = combinedEdgeInsets()
12451262
map.setPadding(
12461263
combined.left + left,
@@ -1341,14 +1358,79 @@ class GoogleMapProvider(private val context: Context) :
13411358
val offsetY = (edgeInsets.top - edgeInsets.bottom) / 2.0
13421359
if (offsetX == 0.0 && offsetY == 0.0) return LatLng(initialLatitude, initialLongitude)
13431360

1344-
// Web mercator world size in px at the initial zoom
1345-
val worldSize = 256f.dpToPx().toDouble() * 2.0.pow(initialZoom.toDouble())
1346-
val sinLat = sin(Math.toRadians(initialLatitude))
1347-
val x = (initialLongitude + 180.0) / 360.0 - offsetX / worldSize
1348-
val y = 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI) - offsetY / worldSize
1349-
return LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0)
1361+
val worldSize = mercatorWorldSize(initialZoom)
1362+
return latLngFromMercator(
1363+
mercatorX(initialLongitude) - offsetX / worldSize,
1364+
mercatorY(initialLatitude) - offsetY / worldSize
1365+
)
1366+
}
1367+
1368+
// A static camera can't fit with map padding (lite mode misrenders it);
1369+
// compute the fitted camera in mercator space instead, shifting the
1370+
// center for asymmetric padding like edge insets
1371+
private fun fitStaticCoordinates(
1372+
coordinates: List<LatLng>,
1373+
top: Int,
1374+
left: Int,
1375+
bottom: Int,
1376+
right: Int
1377+
) {
1378+
val map = googleMap ?: return
1379+
val wrapper = wrapperView ?: return
1380+
if (wrapper.width == 0 || wrapper.height == 0) return
1381+
1382+
var minX = Double.MAX_VALUE
1383+
var minY = Double.MAX_VALUE
1384+
var maxX = -Double.MAX_VALUE
1385+
var maxY = -Double.MAX_VALUE
1386+
for (coordinate in coordinates) {
1387+
val x = mercatorX(coordinate.longitude)
1388+
val y = mercatorY(coordinate.latitude)
1389+
minX = minOf(minX, x)
1390+
maxX = maxOf(maxX, x)
1391+
minY = minOf(minY, y)
1392+
maxY = maxOf(maxY, y)
1393+
}
1394+
1395+
val insets = combinedEdgeInsets()
1396+
val availWidth = (wrapper.width - insets.left - insets.right - left - right).coerceAtLeast(1)
1397+
val availHeight = (wrapper.height - insets.top - insets.bottom - top - bottom).coerceAtLeast(1)
1398+
1399+
// World size in px at which the bounds fit the padded viewport;
1400+
// infinite for coincident coordinates - keep the current zoom then
1401+
val fitWorldSize = minOf(availWidth / (maxX - minX), availHeight / (maxY - minY))
1402+
if (fitWorldSize.isFinite()) {
1403+
initialZoom = log2(fitWorldSize / 256f.dpToPx())
1404+
.toFloat()
1405+
.coerceIn(map.minZoomLevel, map.maxZoomLevel)
1406+
}
1407+
1408+
val worldSize = mercatorWorldSize(initialZoom)
1409+
val center = latLngFromMercator(
1410+
(minX + maxX) / 2.0 - (left - right) / 2.0 / worldSize,
1411+
(minY + maxY) / 2.0 - (top - bottom) / 2.0 / worldSize
1412+
)
1413+
initialLatitude = center.latitude
1414+
initialLongitude = center.longitude
1415+
1416+
map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1417+
positionLiveMarkers()
13501418
}
13511419

1420+
// Web mercator world space in [0, 1]
1421+
private fun mercatorX(longitude: Double): Double = (longitude + 180.0) / 360.0
1422+
1423+
private fun mercatorY(latitude: Double): Double {
1424+
val sinLat = sin(Math.toRadians(latitude))
1425+
return 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI)
1426+
}
1427+
1428+
private fun latLngFromMercator(x: Double, y: Double): LatLng =
1429+
LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0)
1430+
1431+
// World size in px at the given zoom
1432+
private fun mercatorWorldSize(zoom: Float): Double = 256f.dpToPx().toDouble() * 2.0.pow(zoom.toDouble())
1433+
13521434
private fun applyWatermarkTranslation(insets: EdgeInsets, duration: Int = 0) {
13531435
val view = mapView ?: return
13541436
view.findViewByTag("GoogleWatermark")?.let { watermark ->

docs/MAPVIEW.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,10 @@ mounting many live maps is expensive:
9292
Notes:
9393

9494
- `staticMode` is creation-time only and cannot be toggled after the map is created.
95-
- The map renders once with its initial camera and children. Camera commands
96-
and prop updates after the snapshot are ignored on iOS.
95+
- Ref methods (`moveCamera`, `fitCoordinates`, `setEdgeInsets`) work like on
96+
a live map, minus animation - `duration` is ignored and the map re-renders
97+
at the final camera (iOS) or re-centers (Android). Map-setting prop
98+
updates (e.g. `mapType`) after the snapshot are still ignored on iOS.
9799
- `edgeInsets` shift the visible center like on a live map, so the
98100
coordinate centers in the inset viewport. Changing insets after the
99101
render re-renders the base map (iOS) or re-centers the camera (Android).

ios/LuggMapView.mm

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -248,18 +248,26 @@ - (nullable NSString *)staticSnapshotCacheKey {
248248
: -(NSInteger)_theme;
249249

250250
// Camera is part of the key so a reused staticKey with a different
251-
// location/zoom can't serve a stale snapshot
251+
// location/zoom can't serve a stale snapshot. The provider's camera (when
252+
// available) reflects imperative camera changes, so a re-rendered
253+
// snapshot is keyed on what it actually shows.
252254
const auto &viewProps =
253255
*std::static_pointer_cast<LuggMapViewProps const>(_props);
256+
double latitude = viewProps.initialCoordinate.latitude;
257+
double longitude = viewProps.initialCoordinate.longitude;
258+
double zoom = viewProps.initialZoom;
259+
if ([_provider isKindOfClass:[StaticMapProviderBase class]]) {
260+
StaticMapProviderBase *staticProvider = (StaticMapProviderBase *)_provider;
261+
latitude = staticProvider.coordinate.latitude;
262+
longitude = staticProvider.coordinate.longitude;
263+
zoom = staticProvider.zoom;
264+
}
254265

255266
return [NSString
256267
stringWithFormat:@"%@|%d|%@|%d|%ld|%.0fx%.0f|%.6f,%.6f,%.2f|%@",
257268
_staticKey, (int)_providerType, _mapId, (int)_mapType,
258-
(long)style, size.width, size.height,
259-
viewProps.initialCoordinate.latitude,
260-
viewProps.initialCoordinate.longitude,
261-
viewProps.initialZoom,
262-
NSStringFromUIEdgeInsets(_edgeInsets)];
269+
(long)style, size.width, size.height, latitude,
270+
longitude, zoom, NSStringFromUIEdgeInsets(_edgeInsets)];
263271
}
264272

265273
- (void)startStaticContent {
@@ -322,14 +330,6 @@ - (void)initializeProvider {
322330
_provider = google;
323331
}
324332

325-
if (_staticMode) {
326-
NSString *cacheKey = [self staticSnapshotCacheKey];
327-
if (cacheKey) {
328-
((StaticMapProviderBase *)_provider).cachedBaseImage =
329-
[StaticSnapshotCache() objectForKey:cacheKey];
330-
}
331-
}
332-
333333
_provider.delegate = self;
334334
_provider.staticMode = _staticMode;
335335

@@ -341,6 +341,16 @@ - (void)initializeProvider {
341341
initialCoordinate:coordinate
342342
initialZoom:viewProps.initialZoom];
343343

344+
// After initializeMapInView so the cache key reads the provider's camera;
345+
// the base render is async and picks up the cached image
346+
if (_staticMode) {
347+
NSString *cacheKey = [self staticSnapshotCacheKey];
348+
if (cacheKey) {
349+
((StaticMapProviderBase *)_provider).cachedBaseImage =
350+
[StaticSnapshotCache() objectForKey:cacheKey];
351+
}
352+
}
353+
344354
// Apply cached props after map view is created
345355
[self applyProps];
346356
[_provider setEdgeInsets:_edgeInsets oldEdgeInsets:UIEdgeInsetsZero];

ios/core/AppleStaticMapProvider.mm

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,19 @@ - (UIView *)newDefaultMarkerOverlayView {
7474
return pin;
7575
}
7676

77+
// Inverse of LuggStaticFittedMapRect: the pre-fit rect is square in map
78+
// points with side latitudeDelta * worldHeight / (360 * cos(lat)), and the
79+
// aspect fit scales it to the view's short side
80+
- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint {
81+
CGSize size = self.projectedSize;
82+
double side = mapPointsPerPoint * MIN(size.width, size.height);
83+
double latitudeDelta = side * 360.0 *
84+
cos(self.coordinate.latitude * M_PI / 180.0) /
85+
MKMapSizeWorld.height;
86+
double zoom = 0.5 + log2(360.0 / latitudeDelta);
87+
return MAX(MIN(zoom, 28.0), 0.0);
88+
}
89+
7790
- (UITraitCollection *)snapshotTraitCollection {
7891
UITraitCollection *base = self.wrapperView.traitCollection;
7992
UIUserInterfaceStyle style;

ios/core/GoogleStaticMapProvider.mm

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ - (UIView *)newDefaultMarkerOverlayView {
7474
[[UIImageView alloc] initWithImage:[GMSMarker markerImageWithColor:nil]];
7575
}
7676

77+
// Inverse of mapRectForSize
78+
- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint {
79+
double zoom = log2(MKMapSizeWorld.width / (256.0 * mapPointsPerPoint));
80+
return MAX(MIN(zoom, (double)kGMSMaxZoomLevel), (double)kGMSMinZoomLevel);
81+
}
82+
7783
- (void)renderBaseMap {
7884
if (_warmupMapView) {
7985
// Retry a swap that couldn't run (e.g. view was off-window)

ios/core/StaticMapProviderBase.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ CGPoint LuggStaticPointForCoordinate(MKMapRect mapRect, CGSize size,
5656
/// Overlay view shown for markers without a custom child view.
5757
- (UIView *)newDefaultMarkerOverlayView;
5858

59+
/// Zoom level at which the base map renders at the given scale (map points
60+
/// per view point) for the current coordinate and view size; the inverse of
61+
/// mapRectForSize. Used by fitCoordinates.
62+
- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint;
63+
5964
/// Starts rendering the base map for the current projection (guaranteed
6065
/// projectionReady, no cached image). Called after mount (once initial
6166
/// props have applied), after a resize, and from resumeAnimations to retry

ios/core/StaticMapProviderBase.mm

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,25 @@ - (void)wrapperDidLayout {
386386
[self renderBaseMapIfNeeded];
387387
}
388388

389+
// Camera commands invalidate any cached, displayed or in-flight base
390+
// render and re-render at the new camera
391+
- (void)rerenderBaseMap {
392+
[self cancelBaseRender];
393+
_baseRenderDone = NO;
394+
self.cachedBaseImage = nil;
395+
396+
// Without a layout yet, the first layout renders with the new camera
397+
if (!_projectionReady)
398+
return;
399+
400+
[self updateProjection];
401+
402+
__weak StaticMapProviderBase *weakSelf = self;
403+
dispatch_async(dispatch_get_main_queue(), ^{
404+
[weakSelf renderBaseMapIfNeeded];
405+
});
406+
}
407+
389408
- (void)renderBaseMapIfNeeded {
390409
if (_baseRenderDone || !_projectionReady || !_wrapperView)
391410
return;
@@ -436,6 +455,10 @@ - (UIView *)newDefaultMarkerOverlayView {
436455
return [[UIView alloc] init];
437456
}
438457

458+
- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint {
459+
return _zoom;
460+
}
461+
439462
- (void)renderBaseMap {
440463
}
441464

@@ -758,12 +781,17 @@ - (void)resumeAnimations {
758781

759782
#pragma mark - Commands
760783

761-
// Static maps render once with their initial camera
784+
// Static maps don't animate; commands re-render once at the final camera
762785

763786
- (void)moveCamera:(double)latitude
764787
longitude:(double)longitude
765788
zoom:(double)zoom
766789
duration:(double)duration {
790+
_coordinate = CLLocationCoordinate2DMake(latitude, longitude);
791+
if (zoom > 0) {
792+
_zoom = zoom;
793+
}
794+
[self rerenderBaseMap];
767795
}
768796

769797
- (void)fitCoordinates:(NSArray *)coordinates
@@ -772,6 +800,46 @@ - (void)fitCoordinates:(NSArray *)coordinates
772800
edgeInsetsBottom:(double)edgeInsetsBottom
773801
edgeInsetsRight:(double)edgeInsetsRight
774802
duration:(double)duration {
803+
if (coordinates.count == 0 || !_projectionReady)
804+
return;
805+
806+
MKMapRect boundsRect = MKMapRectNull;
807+
for (NSDictionary *coordinate in coordinates) {
808+
MKMapPoint point = MKMapPointForCoordinate(
809+
CLLocationCoordinate2DMake([coordinate[@"latitude"] doubleValue],
810+
[coordinate[@"longitude"] doubleValue]));
811+
boundsRect =
812+
MKMapRectUnion(boundsRect, MKMapRectMake(point.x, point.y, 0, 0));
813+
}
814+
815+
double availWidth =
816+
MAX(_projectedSize.width - _edgeInsets.left - _edgeInsets.right -
817+
edgeInsetsLeft - edgeInsetsRight,
818+
1.0);
819+
double availHeight =
820+
MAX(_projectedSize.height - _edgeInsets.top - _edgeInsets.bottom -
821+
edgeInsetsTop - edgeInsetsBottom,
822+
1.0);
823+
824+
// Map points per view point at which the bounds fit the padded viewport
825+
double scale = MAX(boundsRect.size.width / availWidth,
826+
boundsRect.size.height / availHeight);
827+
MKMapPoint center = MKMapPointMake(MKMapRectGetMidX(boundsRect),
828+
MKMapRectGetMidY(boundsRect));
829+
if (scale > 0) {
830+
_coordinate = MKCoordinateForMapPoint(center);
831+
_zoom = [self zoomForMapPointsPerPoint:scale];
832+
} else {
833+
// Coincident coordinates; keep the current zoom
834+
scale = _mapRect.size.width / _projectedSize.width;
835+
}
836+
837+
// Asymmetric padding shifts the visible center, like edge insets
838+
center.x -= (edgeInsetsLeft - edgeInsetsRight) / 2.0 * scale;
839+
center.y -= (edgeInsetsTop - edgeInsetsBottom) / 2.0 * scale;
840+
_coordinate = MKCoordinateForMapPoint(center);
841+
842+
[self rerenderBaseMap];
775843
}
776844

777845
@end

0 commit comments

Comments
 (0)