@lugg/maps 1.0.0-beta.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +15 -100
  2. package/android/src/main/java/com/luggmaps/LuggCalloutView.kt +5 -0
  3. package/android/src/main/java/com/luggmaps/LuggMapView.kt +56 -2
  4. package/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +18 -0
  5. package/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +36 -2
  6. package/android/src/main/java/com/luggmaps/LuggMarkerView.kt +7 -2
  7. package/android/src/main/java/com/luggmaps/LuggPolylineView.kt +2 -1
  8. package/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +332 -42
  9. package/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt +6 -0
  10. package/android/src/main/java/com/luggmaps/core/PolylineAnimator.kt +61 -10
  11. package/ios/LuggMapView.mm +240 -10
  12. package/ios/LuggMapWrapperView.h +5 -0
  13. package/ios/LuggMapWrapperView.mm +18 -0
  14. package/ios/LuggMarkerView.mm +2 -0
  15. package/ios/core/AppleMapProvider.h +8 -0
  16. package/ios/core/AppleMapProvider.mm +63 -47
  17. package/ios/core/AppleStaticMapProvider.h +14 -0
  18. package/ios/core/AppleStaticMapProvider.mm +233 -0
  19. package/ios/core/GMSPolylineAnimator.m +40 -15
  20. package/ios/core/GoogleMapProvider.h +10 -0
  21. package/ios/core/GoogleMapProvider.mm +67 -54
  22. package/ios/core/GoogleStaticMapProvider.h +21 -0
  23. package/ios/core/GoogleStaticMapProvider.mm +217 -0
  24. package/ios/core/MKPolylineAnimator.m +67 -43
  25. package/ios/core/MapEdgeInsets.h +18 -0
  26. package/ios/core/MapProviderDelegate.h +11 -0
  27. package/ios/core/StaticMapProviderBase.h +80 -0
  28. package/ios/core/StaticMapProviderBase.mm +847 -0
  29. package/ios/events/CameraIdleEvent.h +4 -2
  30. package/ios/events/CameraMoveEvent.h +4 -2
  31. package/ios/events/CirclePressEvent.h +4 -2
  32. package/ios/events/GroundOverlayPressEvent.h +4 -2
  33. package/ios/events/LongPressEvent.h +4 -2
  34. package/ios/events/MarkerDragEvent.h +12 -6
  35. package/ios/events/MarkerPressEvent.h +4 -2
  36. package/ios/events/PolygonPressEvent.h +4 -2
  37. package/ios/events/PressEvent.h +4 -2
  38. package/ios/events/ReadyEvent.h +4 -2
  39. package/ios/events/TileOverlayPressEvent.h +4 -2
  40. package/ios/extensions/MKMapView+Zoom.h +20 -0
  41. package/ios/extensions/MKMapView+Zoom.m +28 -18
  42. package/ios/extensions/UIView+Snapshot.h +16 -0
  43. package/ios/extensions/UIView+Snapshot.m +27 -0
  44. package/lib/module/MapView.js +12 -0
  45. package/lib/module/MapView.js.map +1 -1
  46. package/lib/module/MapView.web.js +29 -10
  47. package/lib/module/MapView.web.js.map +1 -1
  48. package/lib/module/fabric/LuggMapViewNativeComponent.ts +10 -1
  49. package/lib/typescript/src/MapView.d.ts +1 -0
  50. package/lib/typescript/src/MapView.d.ts.map +1 -1
  51. package/lib/typescript/src/MapView.types.d.ts +32 -0
  52. package/lib/typescript/src/MapView.types.d.ts.map +1 -1
  53. package/lib/typescript/src/MapView.web.d.ts.map +1 -1
  54. package/lib/typescript/src/fabric/LuggMapViewNativeComponent.d.ts +4 -0
  55. package/lib/typescript/src/fabric/LuggMapViewNativeComponent.d.ts.map +1 -1
  56. package/package.json +24 -14
  57. package/src/MapView.tsx +14 -0
  58. package/src/MapView.types.ts +32 -0
  59. package/src/MapView.web.tsx +39 -14
  60. package/src/fabric/LuggMapViewNativeComponent.ts +10 -1
  61. package/plugin/build/withMapsAndroid.d.ts +0 -5
  62. package/plugin/build/withMapsAndroid.js +0 -15
  63. package/plugin/build/withMapsIOS.d.ts +0 -5
  64. package/plugin/build/withMapsIOS.js +0 -27
@@ -5,6 +5,7 @@ import android.content.ComponentCallbacks2
5
5
  import android.content.Context
6
6
  import android.content.res.Configuration
7
7
  import android.view.View
8
+ import android.view.ViewTreeObserver
8
9
  import android.widget.ImageView
9
10
  import androidx.core.graphics.createBitmap
10
11
  import androidx.core.view.ViewCompat
@@ -15,9 +16,8 @@ import com.google.android.gms.maps.GoogleMap
15
16
  import com.google.android.gms.maps.GoogleMapOptions
16
17
  import com.google.android.gms.maps.MapView
17
18
  import com.google.android.gms.maps.OnMapReadyCallback
18
- import com.google.android.gms.maps.model.AdvancedMarker
19
- import com.google.android.gms.maps.model.AdvancedMarkerOptions
20
19
  import com.google.android.gms.maps.model.BitmapDescriptorFactory
20
+ import com.google.android.gms.maps.model.CameraPosition
21
21
  import com.google.android.gms.maps.model.Circle
22
22
  import com.google.android.gms.maps.model.CircleOptions
23
23
  import com.google.android.gms.maps.model.GroundOverlay
@@ -26,6 +26,7 @@ import com.google.android.gms.maps.model.LatLng
26
26
  import com.google.android.gms.maps.model.LatLngBounds
27
27
  import com.google.android.gms.maps.model.MapColorScheme
28
28
  import com.google.android.gms.maps.model.Marker
29
+ import com.google.android.gms.maps.model.MarkerOptions
29
30
  import com.google.android.gms.maps.model.Polygon
30
31
  import com.google.android.gms.maps.model.PolygonOptions
31
32
  import com.google.android.gms.maps.model.PolylineOptions
@@ -48,7 +49,10 @@ import com.luggmaps.LuggTileOverlayViewDelegate
48
49
  import com.luggmaps.extensions.findViewByTag
49
50
  import java.net.URL
50
51
  import kotlin.math.atan
52
+ import kotlin.math.ln
53
+ import kotlin.math.log2
51
54
  import kotlin.math.pow
55
+ import kotlin.math.sin
52
56
  import kotlin.math.sinh
53
57
 
54
58
  class GoogleMapProvider(private val context: Context) :
@@ -78,6 +82,10 @@ class GoogleMapProvider(private val context: Context) :
78
82
 
79
83
  var mapId: String = DEMO_MAP_ID
80
84
 
85
+ // Renders a non-interactive static map — lite mode when the view fits
86
+ // the lite bitmap cap, a gesture-less full map otherwise. Creation-time only.
87
+ var staticMode: Boolean = false
88
+
81
89
  private var wrapperView: LuggMapWrapperView? = null
82
90
  private var currentNightMode: Int = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
83
91
  private var mapView: MapView? = null
@@ -96,6 +104,7 @@ class GoogleMapProvider(private val context: Context) :
96
104
  private val groundOverlayToViewMap = mutableMapOf<GroundOverlay, LuggGroundOverlayView>()
97
105
  private val markerToViewMap = mutableMapOf<Marker, LuggMarkerView>()
98
106
  private val liveMarkerViews = mutableSetOf<LuggMarkerView>()
107
+ private var staticProjectionReady = false
99
108
  private var activeNonBubbledMarker: Marker? = null
100
109
  private var tapLocation: LatLng? = null
101
110
 
@@ -109,6 +118,7 @@ class GoogleMapProvider(private val context: Context) :
109
118
  private var scrollEnabled: Boolean = true
110
119
  private var rotateEnabled: Boolean = true
111
120
  private var pitchEnabled: Boolean = true
121
+ private var compassEnabled: Boolean = true
112
122
  private var userLocationEnabled: Boolean = false
113
123
  private var userLocationButtonEnabled: Boolean = false
114
124
 
@@ -118,6 +128,7 @@ class GoogleMapProvider(private val context: Context) :
118
128
 
119
129
  // Theme
120
130
  private var theme: String = "system"
131
+ private var mapType: String = "standard"
121
132
 
122
133
  // Edge Insets
123
134
  private var edgeInsets: EdgeInsets = EdgeInsets()
@@ -126,6 +137,13 @@ class GoogleMapProvider(private val context: Context) :
126
137
  private var insetAdjustment: String = "never"
127
138
  private var systemInsets: EdgeInsets = EdgeInsets()
128
139
 
140
+ // The maps renderer adds and positions the watermark asynchronously; the
141
+ // layout listener re-evaluates the translation on every layout pass
142
+ private var watermarkLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
143
+ private var watermarkView: View? = null
144
+ private var watermarkTargetX = 0f
145
+ private var watermarkTargetY = 0f
146
+
129
147
  // region MapProvider
130
148
 
131
149
  override fun initializeMap(wrapperView: View, latitude: Double, longitude: Double, zoom: Float) {
@@ -136,21 +154,45 @@ class GoogleMapProvider(private val context: Context) :
136
154
  initialZoom = zoom
137
155
 
138
156
  val wrapper = wrapperView as LuggMapWrapperView
157
+ wrapper.relayoutChildOnRequest = staticMode
139
158
  this.wrapperView = wrapper
140
159
 
141
160
  context.applicationContext.registerComponentCallbacks(this)
142
161
 
143
- val options = GoogleMapOptions().mapId(mapId)
162
+ // Static mode needs the view size to pick lite vs full map, so wait
163
+ // for the first layout if it hasn't happened yet
164
+ if (staticMode && (wrapper.width == 0 || wrapper.height == 0)) {
165
+ wrapper.onLayoutReady = { createMapView() }
166
+ } else {
167
+ createMapView()
168
+ }
169
+ }
170
+
171
+ private fun createMapView() {
172
+ if (mapView != null) return
173
+ val wrapper = wrapperView ?: return
174
+
175
+ // Lite mode renders the map as a single bitmap capped at ~2048px per
176
+ // dimension, centered in the view — bigger views get letterboxed. Fall
177
+ // back to a full map (gestures disabled) when the view exceeds the cap
178
+ val useLite = staticMode && maxOf(wrapper.width, wrapper.height) <= LITE_MODE_MAX_SIZE
179
+ val options = GoogleMapOptions().liteMode(useLite)
180
+ // Lite mode doesn't support map IDs (cloud-based styling); setting one
181
+ // makes the static map render blank
182
+ if (!useLite) options.mapId(mapId)
144
183
  mapView = MapView(context, options).also { view ->
145
184
  view.onCreate(null)
146
185
  view.onResume()
147
186
  view.getMapAsync(this)
148
- wrapper.addView(view)
187
+ wrapper.addView(view, 0)
149
188
  }
189
+ wrapper.onLayoutReady = null
150
190
  }
151
191
 
152
192
  override fun destroy() {
193
+ wrapperView?.onLayoutReady = null
153
194
  detachWindowInsetsListener()
195
+ detachWatermarkLayoutListener()
154
196
  context.applicationContext.unregisterComponentCallbacks(this)
155
197
  dismissNonBubbledCallout()
156
198
  for (markerView in liveMarkerViews) {
@@ -170,6 +212,9 @@ class GoogleMapProvider(private val context: Context) :
170
212
  groundOverlayToViewMap.clear()
171
213
  markerToViewMap.clear()
172
214
  wrapperView?.touchEventHandler = null
215
+ // The wrapper outlives the provider on reload; don't leave the dead map
216
+ // view in it
217
+ mapView?.let { wrapperView?.removeView(it) }
173
218
  wrapperView = null
174
219
  googleMap?.setOnCameraMoveStartedListener(null)
175
220
  googleMap?.setOnCameraMoveListener(null)
@@ -194,7 +239,23 @@ class GoogleMapProvider(private val context: Context) :
194
239
  googleMap = map
195
240
  _isMapReady = true
196
241
 
197
- val position = LatLng(initialLatitude, initialLongitude)
242
+ if (staticMode) {
243
+ // Tapping the map otherwise shows an "open in Google Maps" toolbar
244
+ map.uiSettings.isMapToolbarEnabled = false
245
+ // The projection is only valid once the map has rendered, and no
246
+ // camera events fire afterwards to correct live marker positions
247
+ map.setOnMapLoadedCallback {
248
+ staticProjectionReady = true
249
+ positionLiveMarkers()
250
+ }
251
+ }
252
+
253
+ // Padding must be in place before the initial camera move: setPadding
254
+ // never repositions the current camera, but camera updates after it are
255
+ // relative to the padded center
256
+ applyEdgeInsets()
257
+
258
+ val position = if (staticMode) staticCameraTarget() else LatLng(initialLatitude, initialLongitude)
198
259
  map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom))
199
260
 
200
261
  map.setOnCameraMoveStartedListener(this)
@@ -217,9 +278,9 @@ class GoogleMapProvider(private val context: Context) :
217
278
 
218
279
  applyUiSettings()
219
280
  applyZoomLimits()
220
- applyEdgeInsets()
221
281
  applyInsetAdjustment()
222
282
  applyTheme()
283
+ setMapType(mapType)
223
284
  applyUserLocation()
224
285
  processPendingMarkers()
225
286
  processPendingPolylines()
@@ -441,24 +502,33 @@ class GoogleMapProvider(private val context: Context) :
441
502
 
442
503
  override fun setZoomEnabled(enabled: Boolean) {
443
504
  zoomEnabled = enabled
505
+ if (staticMode) return
444
506
  googleMap?.uiSettings?.isZoomGesturesEnabled = enabled
445
507
  }
446
508
 
447
509
  override fun setScrollEnabled(enabled: Boolean) {
448
510
  scrollEnabled = enabled
511
+ if (staticMode) return
449
512
  googleMap?.uiSettings?.isScrollGesturesEnabled = enabled
450
513
  }
451
514
 
452
515
  override fun setRotateEnabled(enabled: Boolean) {
453
516
  rotateEnabled = enabled
517
+ if (staticMode) return
454
518
  googleMap?.uiSettings?.isRotateGesturesEnabled = enabled
455
519
  }
456
520
 
457
521
  override fun setPitchEnabled(enabled: Boolean) {
458
522
  pitchEnabled = enabled
523
+ if (staticMode) return
459
524
  googleMap?.uiSettings?.isTiltGesturesEnabled = enabled
460
525
  }
461
526
 
527
+ override fun setCompassEnabled(enabled: Boolean) {
528
+ compassEnabled = enabled
529
+ googleMap?.uiSettings?.isCompassEnabled = enabled
530
+ }
531
+
462
532
  @SuppressLint("MissingPermission")
463
533
  override fun setUserLocationEnabled(enabled: Boolean) {
464
534
  userLocationEnabled = enabled
@@ -477,6 +547,7 @@ class GoogleMapProvider(private val context: Context) :
477
547
  }
478
548
 
479
549
  override fun setMapType(value: String) {
550
+ mapType = value
480
551
  googleMap?.mapType = when (value) {
481
552
  "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
482
553
  "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
@@ -505,6 +576,11 @@ class GoogleMapProvider(private val context: Context) :
505
576
  }
506
577
 
507
578
  override fun setEdgeInsets(edgeInsets: EdgeInsets) {
579
+ if (staticMode) {
580
+ setStaticEdgeInsets(edgeInsets)
581
+ return
582
+ }
583
+
508
584
  val oldInsets = this.edgeInsets
509
585
  this.edgeInsets = edgeInsets
510
586
  applyEdgeInsets()
@@ -518,6 +594,11 @@ class GoogleMapProvider(private val context: Context) :
518
594
  }
519
595
 
520
596
  override fun setEdgeInsets(edgeInsets: EdgeInsets, duration: Int) {
597
+ if (staticMode) {
598
+ setStaticEdgeInsets(edgeInsets)
599
+ return
600
+ }
601
+
521
602
  val map = googleMap
522
603
  val oldInsets = this.edgeInsets
523
604
  this.edgeInsets = edgeInsets
@@ -599,6 +680,10 @@ class GoogleMapProvider(private val context: Context) :
599
680
  syncPolylineView(polylineView)
600
681
  }
601
682
 
683
+ override fun polylineViewDidDrop(polylineView: LuggPolylineView) {
684
+ teardownPolyline(polylineView)
685
+ }
686
+
602
687
  // endregion
603
688
 
604
689
  // region PolygonViewDelegate
@@ -692,12 +777,12 @@ class GoogleMapProvider(private val context: Context) :
692
777
  val map = googleMap ?: return
693
778
 
694
779
  val position = LatLng(markerView.latitude, markerView.longitude)
695
- val options = AdvancedMarkerOptions()
780
+ val options = MarkerOptions()
696
781
  .position(position)
697
782
  .title(markerView.title)
698
783
  .snippet(markerView.description)
699
784
 
700
- val marker = map.addMarker(options) as AdvancedMarker
785
+ val marker = map.addMarker(options) ?: return
701
786
  marker.setAnchor(markerView.anchorX, markerView.anchorY)
702
787
  marker.zIndex = markerView.zIndex
703
788
  marker.rotation = markerView.rotate
@@ -715,9 +800,8 @@ class GoogleMapProvider(private val context: Context) :
715
800
  }
716
801
  }
717
802
 
718
- // Workaround: AdvancedMarker.iconView is buggy on Android, so we manually add the custom
719
- // content view to the wrapper and position it via screen projection instead. The underlying
720
- // marker uses a transparent bitmap matching the content size so taps still trigger onMarkerClick.
803
+ // Live marker: content view is added to the wrapper and positioned via screen projection.
804
+ // The underlying marker uses a transparent bitmap matching the content size so taps still trigger onMarkerClick.
721
805
  private fun showLiveMarker(markerView: LuggMarkerView) {
722
806
  val wrapper = wrapperView ?: return
723
807
 
@@ -728,6 +812,10 @@ class GoogleMapProvider(private val context: Context) :
728
812
 
729
813
  val contentView = markerView.contentView
730
814
  contentView.pointerEvents = com.facebook.react.uimanager.PointerEvents.NONE
815
+ // Until the lite map's projection is valid, hide the marker instead of
816
+ // flashing it at a wrong position; onMapLoaded positions and shows it
817
+ contentView.visibility =
818
+ if (staticMode && !staticProjectionReady) View.INVISIBLE else View.VISIBLE
731
819
  (contentView.parent as? android.view.ViewGroup)?.removeView(contentView)
732
820
  wrapper.addView(contentView)
733
821
  liveMarkerViews.add(markerView)
@@ -759,10 +847,12 @@ class GoogleMapProvider(private val context: Context) :
759
847
 
760
848
  private fun positionLiveMarker(markerView: LuggMarkerView) {
761
849
  val map = googleMap ?: return
850
+ if (staticMode && !staticProjectionReady) return
762
851
  val contentView = markerView.contentView
763
852
  val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude))
764
853
  contentView.translationX = point.x - contentView.width * markerView.anchorX
765
854
  contentView.translationY = point.y - contentView.height * markerView.anchorY
855
+ contentView.visibility = View.VISIBLE
766
856
  }
767
857
 
768
858
  // endregion
@@ -775,8 +865,12 @@ class GoogleMapProvider(private val context: Context) :
775
865
  }
776
866
 
777
867
  override fun removePolylineView(polylineView: LuggPolylineView) {
778
- polylineAnimators[polylineView]?.destroy()
779
- polylineAnimators.remove(polylineView)
868
+ teardownPolyline(polylineView)
869
+ }
870
+
871
+ private fun teardownPolyline(polylineView: LuggPolylineView) {
872
+ pendingPolylineViews.remove(polylineView)
873
+ polylineAnimators.remove(polylineView)?.destroy()
780
874
  polylineView.polyline?.remove()
781
875
  polylineView.polyline = null
782
876
  }
@@ -792,15 +886,13 @@ class GoogleMapProvider(private val context: Context) :
792
886
  return
793
887
  }
794
888
 
795
- polylineView.polyline?.width = polylineView.strokeWidth.dpToPx()
796
- polylineView.polyline?.zIndex = polylineView.zIndex
797
-
798
889
  polylineAnimators[polylineView]?.apply {
799
890
  coordinates = polylineView.coordinates
800
891
  strokeColors = polylineView.strokeColors
801
892
  strokeWidth = polylineView.strokeWidth.dpToPx()
893
+ zIndex = polylineView.zIndex
802
894
  animatedOptions = polylineView.animatedOptions
803
- animated = polylineView.animated
895
+ animated = polylineView.animated && !staticMode
804
896
  update()
805
897
  }
806
898
  }
@@ -814,6 +906,8 @@ class GoogleMapProvider(private val context: Context) :
814
906
  private fun addPolylineViewToMap(polylineView: LuggPolylineView) {
815
907
  val map = googleMap ?: return
816
908
 
909
+ polylineAnimators.remove(polylineView)?.destroy()
910
+
817
911
  val options = PolylineOptions()
818
912
  .width(polylineView.strokeWidth.dpToPx())
819
913
  .zIndex(polylineView.zIndex)
@@ -826,8 +920,10 @@ class GoogleMapProvider(private val context: Context) :
826
920
  coordinates = polylineView.coordinates
827
921
  strokeColors = polylineView.strokeColors
828
922
  strokeWidth = polylineView.strokeWidth.dpToPx()
923
+ zIndex = polylineView.zIndex
829
924
  animatedOptions = polylineView.animatedOptions
830
- animated = polylineView.animated
925
+ // Static maps render once; show the full polyline instead of animating
926
+ animated = polylineView.animated && !staticMode
831
927
  update()
832
928
  }
833
929
 
@@ -1111,6 +1207,24 @@ class GoogleMapProvider(private val context: Context) :
1111
1207
 
1112
1208
  // endregion
1113
1209
 
1210
+ // region Camera
1211
+
1212
+ // A static map's camera is inset-shifted (staticCameraTarget), so report
1213
+ // the requested camera instead
1214
+ private val liveCameraPosition: CameraPosition?
1215
+ get() = if (staticMode) null else googleMap?.cameraPosition
1216
+
1217
+ override val cameraLatitude: Double
1218
+ get() = liveCameraPosition?.target?.latitude ?: initialLatitude
1219
+
1220
+ override val cameraLongitude: Double
1221
+ get() = liveCameraPosition?.target?.longitude ?: initialLongitude
1222
+
1223
+ override val cameraZoom: Float
1224
+ get() = liveCameraPosition?.zoom ?: initialZoom
1225
+
1226
+ // endregion
1227
+
1114
1228
  // region Lifecycle
1115
1229
 
1116
1230
  override fun pauseAnimations() {
@@ -1126,10 +1240,25 @@ class GoogleMapProvider(private val context: Context) :
1126
1240
  // region Commands
1127
1241
 
1128
1242
  override fun moveCamera(latitude: Double, longitude: Double, zoom: Double, duration: Int) {
1243
+ if (staticMode) {
1244
+ // Static maps don't animate; re-center at the final camera
1245
+ initialLatitude = latitude
1246
+ initialLongitude = longitude
1247
+ if (zoom > 0) initialZoom = zoom.toFloat()
1248
+ val map = googleMap ?: return
1249
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1250
+ positionLiveMarkers()
1251
+ return
1252
+ }
1253
+
1129
1254
  val map = googleMap ?: return
1130
1255
  val position = LatLng(latitude, longitude)
1131
1256
  val targetZoom = if (zoom > 0) zoom.toFloat() else map.cameraPosition.zoom
1132
- val cameraUpdate = CameraUpdateFactory.newLatLngZoom(position, targetZoom)
1257
+ val cameraPosition = CameraPosition.Builder(map.cameraPosition)
1258
+ .target(position)
1259
+ .zoom(targetZoom)
1260
+ .build()
1261
+ val cameraUpdate = CameraUpdateFactory.newCameraPosition(cameraPosition)
1133
1262
  when {
1134
1263
  duration < 0 -> map.animateCamera(cameraUpdate)
1135
1264
  duration > 0 -> map.animateCamera(cameraUpdate, duration, null)
@@ -1151,15 +1280,20 @@ class GoogleMapProvider(private val context: Context) :
1151
1280
  val latLongs = coordinates.filterIsInstance<LatLng>()
1152
1281
  if (latLongs.isEmpty()) return
1153
1282
 
1154
- val boundsBuilder = LatLngBounds.Builder()
1155
- latLongs.forEach { boundsBuilder.include(it) }
1156
- val bounds = boundsBuilder.build()
1157
-
1158
1283
  val top = edgeInsetsTop.toFloat().dpToPx().toInt()
1159
1284
  val left = edgeInsetsLeft.toFloat().dpToPx().toInt()
1160
1285
  val bottom = edgeInsetsBottom.toFloat().dpToPx().toInt()
1161
1286
  val right = edgeInsetsRight.toFloat().dpToPx().toInt()
1162
1287
 
1288
+ if (staticMode) {
1289
+ fitStaticCoordinates(latLongs, top, left, bottom, right)
1290
+ return
1291
+ }
1292
+
1293
+ val boundsBuilder = LatLngBounds.Builder()
1294
+ latLongs.forEach { boundsBuilder.include(it) }
1295
+ val bounds = boundsBuilder.build()
1296
+
1163
1297
  val combined = combinedEdgeInsets()
1164
1298
  map.setPadding(
1165
1299
  combined.left + left,
@@ -1185,10 +1319,17 @@ class GoogleMapProvider(private val context: Context) :
1185
1319
 
1186
1320
  private fun applyUiSettings() {
1187
1321
  googleMap?.uiSettings?.apply {
1322
+ if (staticMode) {
1323
+ // The non-lite fallback is a full map; keep it behaving like a
1324
+ // static image (lite mode ignores gestures anyway)
1325
+ setAllGesturesEnabled(false)
1326
+ return
1327
+ }
1188
1328
  isZoomGesturesEnabled = zoomEnabled
1189
1329
  isScrollGesturesEnabled = scrollEnabled
1190
1330
  isRotateGesturesEnabled = rotateEnabled
1191
1331
  isTiltGesturesEnabled = pitchEnabled
1332
+ isCompassEnabled = compassEnabled
1192
1333
  isMyLocationButtonEnabled = userLocationButtonEnabled
1193
1334
  }
1194
1335
  }
@@ -1224,31 +1365,176 @@ class GoogleMapProvider(private val context: Context) :
1224
1365
 
1225
1366
  private fun applyEdgeInsets(duration: Int = 0) {
1226
1367
  val combined = combinedEdgeInsets()
1227
- googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom)
1368
+ // Lite mode misrenders padding: the static image is drawn only within
1369
+ // the padded viewport instead of the full view. The camera target shift
1370
+ // (staticCameraTarget) compensates for insets instead.
1371
+ if (!staticMode) {
1372
+ googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom)
1373
+ }
1228
1374
  applyWatermarkTranslation(combined, duration)
1229
1375
  }
1230
1376
 
1377
+ // Insets are applied by shifting the camera target so the coordinate
1378
+ // centers in the inset viewport like a live map would (see
1379
+ // applyEdgeInsets for why padding can't be used)
1380
+ private fun setStaticEdgeInsets(edgeInsets: EdgeInsets) {
1381
+ val oldInsets = this.edgeInsets
1382
+ this.edgeInsets = edgeInsets
1383
+ applyEdgeInsets()
1384
+
1385
+ val map = googleMap
1386
+ if (map != null && oldInsets != edgeInsets) {
1387
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1388
+ positionLiveMarkers()
1389
+ }
1390
+ }
1391
+
1392
+ private fun staticCameraTarget(): LatLng {
1393
+ val offsetX = (edgeInsets.left - edgeInsets.right) / 2.0
1394
+ val offsetY = (edgeInsets.top - edgeInsets.bottom) / 2.0
1395
+ if (offsetX == 0.0 && offsetY == 0.0) return LatLng(initialLatitude, initialLongitude)
1396
+
1397
+ val worldSize = mercatorWorldSize(initialZoom)
1398
+ return latLngFromMercator(
1399
+ mercatorX(initialLongitude) - offsetX / worldSize,
1400
+ mercatorY(initialLatitude) - offsetY / worldSize
1401
+ )
1402
+ }
1403
+
1404
+ // A static camera can't fit with map padding (lite mode misrenders it);
1405
+ // compute the fitted camera in mercator space instead, shifting the
1406
+ // center for asymmetric padding like edge insets
1407
+ private fun fitStaticCoordinates(
1408
+ coordinates: List<LatLng>,
1409
+ top: Int,
1410
+ left: Int,
1411
+ bottom: Int,
1412
+ right: Int
1413
+ ) {
1414
+ val map = googleMap ?: return
1415
+ val wrapper = wrapperView ?: return
1416
+ if (wrapper.width == 0 || wrapper.height == 0) return
1417
+
1418
+ var minX = Double.MAX_VALUE
1419
+ var minY = Double.MAX_VALUE
1420
+ var maxX = -Double.MAX_VALUE
1421
+ var maxY = -Double.MAX_VALUE
1422
+ for (coordinate in coordinates) {
1423
+ val x = mercatorX(coordinate.longitude)
1424
+ val y = mercatorY(coordinate.latitude)
1425
+ minX = minOf(minX, x)
1426
+ maxX = maxOf(maxX, x)
1427
+ minY = minOf(minY, y)
1428
+ maxY = maxOf(maxY, y)
1429
+ }
1430
+
1431
+ val insets = combinedEdgeInsets()
1432
+ val availWidth = (wrapper.width - insets.left - insets.right - left - right).coerceAtLeast(1)
1433
+ val availHeight = (wrapper.height - insets.top - insets.bottom - top - bottom).coerceAtLeast(1)
1434
+
1435
+ // World size in px at which the bounds fit the padded viewport;
1436
+ // infinite for coincident coordinates - keep the current zoom then
1437
+ val fitWorldSize = minOf(availWidth / (maxX - minX), availHeight / (maxY - minY))
1438
+ if (fitWorldSize.isFinite()) {
1439
+ initialZoom = log2(fitWorldSize / 256f.dpToPx())
1440
+ .toFloat()
1441
+ .coerceIn(map.minZoomLevel, map.maxZoomLevel)
1442
+ }
1443
+
1444
+ val worldSize = mercatorWorldSize(initialZoom)
1445
+ val center = latLngFromMercator(
1446
+ (minX + maxX) / 2.0 - (left - right) / 2.0 / worldSize,
1447
+ (minY + maxY) / 2.0 - (top - bottom) / 2.0 / worldSize
1448
+ )
1449
+ initialLatitude = center.latitude
1450
+ initialLongitude = center.longitude
1451
+
1452
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1453
+ positionLiveMarkers()
1454
+ }
1455
+
1456
+ // Web mercator world space in [0, 1]
1457
+ private fun mercatorX(longitude: Double): Double = (longitude + 180.0) / 360.0
1458
+
1459
+ private fun mercatorY(latitude: Double): Double {
1460
+ val sinLat = sin(Math.toRadians(latitude))
1461
+ return 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI)
1462
+ }
1463
+
1464
+ private fun latLngFromMercator(x: Double, y: Double): LatLng =
1465
+ LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0)
1466
+
1467
+ // World size in px at the given zoom
1468
+ private fun mercatorWorldSize(zoom: Float): Double = 256f.dpToPx().toDouble() * 2.0.pow(zoom.toDouble())
1469
+
1470
+ // Whether the renderer repositions the watermark for setPadding varies by
1471
+ // renderer version and by whether the watermark existed when padding was
1472
+ // set (and lite mode never applies padding). Measure where layout actually
1473
+ // placed it and translate only for the remainder.
1231
1474
  private fun applyWatermarkTranslation(insets: EdgeInsets, duration: Int = 0) {
1232
1475
  val view = mapView ?: return
1233
- view.findViewByTag("GoogleWatermark")?.let { watermark ->
1234
- val targetY = -insets.bottom.toFloat()
1235
- val targetX = insets.left.toFloat()
1236
- if (duration > 0) {
1237
- watermark.animate()
1238
- .translationY(targetY)
1239
- .translationX(targetX)
1240
- .setDuration(duration.toLong())
1241
- .start()
1242
- } else if (duration < 0) {
1243
- watermark.animate()
1244
- .translationY(targetY)
1245
- .translationX(targetX)
1246
- .start()
1247
- } else {
1248
- watermark.translationY = targetY
1249
- watermark.translationX = targetX
1250
- }
1476
+ attachWatermarkLayoutListener()
1477
+
1478
+ if (view.height == 0) return
1479
+ val watermark = view.findViewByTag("GoogleWatermark") ?: return
1480
+
1481
+ // Layout position relative to the map view, excluding translation
1482
+ var left = 0
1483
+ var top = 0
1484
+ var current: View? = watermark
1485
+ while (current != null && current !== view) {
1486
+ left += current.left
1487
+ top += current.top
1488
+ current = current.parent as? View
1489
+ }
1490
+ if (current == null) return
1491
+
1492
+ val bottomGap = view.height - (top + watermark.height)
1493
+ val targetY = if (bottomGap >= insets.bottom) 0f else -insets.bottom.toFloat()
1494
+ val targetX = if (left >= insets.left) 0f else insets.left.toFloat()
1495
+
1496
+ // Skip when already applied (or animating) toward this target so layout
1497
+ // passes don't snap an in-flight animation
1498
+ if (watermark === watermarkView && targetX == watermarkTargetX && targetY == watermarkTargetY) {
1499
+ return
1500
+ }
1501
+ watermarkView = watermark
1502
+ watermarkTargetX = targetX
1503
+ watermarkTargetY = targetY
1504
+
1505
+ if (duration > 0) {
1506
+ watermark.animate()
1507
+ .translationY(targetY)
1508
+ .translationX(targetX)
1509
+ .setDuration(duration.toLong())
1510
+ .start()
1511
+ } else if (duration < 0) {
1512
+ watermark.animate()
1513
+ .translationY(targetY)
1514
+ .translationX(targetX)
1515
+ .start()
1516
+ } else {
1517
+ watermark.translationY = targetY
1518
+ watermark.translationX = targetX
1519
+ }
1520
+ }
1521
+
1522
+ private fun attachWatermarkLayoutListener() {
1523
+ if (watermarkLayoutListener != null) return
1524
+ val view = mapView ?: return
1525
+
1526
+ val listener = ViewTreeObserver.OnGlobalLayoutListener {
1527
+ applyWatermarkTranslation(combinedEdgeInsets())
1251
1528
  }
1529
+ watermarkLayoutListener = listener
1530
+ view.viewTreeObserver.addOnGlobalLayoutListener(listener)
1531
+ }
1532
+
1533
+ private fun detachWatermarkLayoutListener() {
1534
+ watermarkView = null
1535
+ val listener = watermarkLayoutListener ?: return
1536
+ watermarkLayoutListener = null
1537
+ mapView?.viewTreeObserver?.takeIf { it.isAlive }?.removeOnGlobalLayoutListener(listener)
1252
1538
  }
1253
1539
 
1254
1540
  private fun attachWindowInsetsListener() {
@@ -1304,5 +1590,9 @@ class GoogleMapProvider(private val context: Context) :
1304
1590
 
1305
1591
  companion object {
1306
1592
  const val DEMO_MAP_ID = "DEMO_MAP_ID"
1593
+
1594
+ // Undocumented cap on the lite mode bitmap (2048 minus a 2px border,
1595
+ // measured empirically)
1596
+ private const val LITE_MODE_MAX_SIZE = 2046
1307
1597
  }
1308
1598
  }
@@ -30,6 +30,7 @@ interface MapProvider {
30
30
  fun setScrollEnabled(enabled: Boolean)
31
31
  fun setRotateEnabled(enabled: Boolean)
32
32
  fun setPitchEnabled(enabled: Boolean)
33
+ fun setCompassEnabled(enabled: Boolean)
33
34
  fun setUserLocationEnabled(enabled: Boolean)
34
35
  fun setUserLocationButtonEnabled(enabled: Boolean)
35
36
  fun setMapType(value: String)
@@ -57,6 +58,11 @@ interface MapProvider {
57
58
  fun addTileOverlayView(tileOverlayView: LuggTileOverlayView)
58
59
  fun removeTileOverlayView(tileOverlayView: LuggTileOverlayView)
59
60
 
61
+ // Current camera (the initial camera until the map is ready)
62
+ val cameraLatitude: Double
63
+ val cameraLongitude: Double
64
+ val cameraZoom: Float
65
+
60
66
  // Lifecycle
61
67
  fun pauseAnimations()
62
68
  fun resumeAnimations()