Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 92 additions & 10 deletions android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1232,15 +1244,20 @@ class GoogleMapProvider(private val context: Context) :
val latLongs = coordinates.filterIsInstance<LatLng>()
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,
Expand Down Expand Up @@ -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<LatLng>,
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 ->
Expand Down
6 changes: 4 additions & 2 deletions docs/MAPVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
38 changes: 24 additions & 14 deletions ios/LuggMapView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<LuggMapViewProps const>(_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 {
Expand Down Expand Up @@ -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;

Expand All @@ -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];
Expand Down
13 changes: 13 additions & 0 deletions ios/core/AppleStaticMapProvider.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions ios/core/GoogleStaticMapProvider.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions ios/core/StaticMapProviderBase.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 69 additions & 1 deletion ios/core/StaticMapProviderBase.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -436,6 +455,10 @@ - (UIView *)newDefaultMarkerOverlayView {
return [[UIView alloc] init];
}

- (double)zoomForMapPointsPerPoint:(double)mapPointsPerPoint {
return _zoom;
}

- (void)renderBaseMap {
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Loading