@maydon_tech/react-native-nitro-maps 0.1.4 → 0.2.1

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 (222) hide show
  1. package/LICENSE +1 -1
  2. package/NitroMap.podspec +1 -1
  3. package/README.md +82 -9
  4. package/android/CMakeLists.txt +4 -1
  5. package/android/build.gradle +6 -2
  6. package/android/gradle.properties +4 -4
  7. package/android/src/main/cpp/ClusterEngineJNI.cpp +198 -0
  8. package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMap.kt +397 -0
  9. package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMapConfig.kt +53 -0
  10. package/android/src/main/{java → kotlin}/com/margelo/nitro/nitromap/NitroMapPackage.kt +4 -4
  11. package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMapView.kt +73 -0
  12. package/android/src/main/kotlin/com/margelo/nitro/nitromap/UserLocationManager.kt +295 -0
  13. package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/ClusterIconRenderer.kt +111 -0
  14. package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/ClusteringManager.kt +104 -0
  15. package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/NitroClusterEngine.kt +166 -0
  16. package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/MarkerIconFactory.kt +303 -0
  17. package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/MarkerSelectionHandler.kt +72 -0
  18. package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/PriceMarkerRenderer.kt +159 -0
  19. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/MapProviderFactory.kt +24 -0
  20. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/MapProviderInterface.kt +128 -0
  21. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapDelegate.kt +317 -0
  22. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Clustering.kt +524 -0
  23. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Markers.kt +358 -0
  24. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Overlays.kt +272 -0
  25. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+UserLocation.kt +296 -0
  26. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider.kt +815 -0
  27. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/MarkerTagData.kt +19 -0
  28. package/ios/Location/NitroLocationManager.swift +116 -0
  29. package/ios/MarkerRenderer/MarkerIconFactory.swift +1 -3
  30. package/ios/MarkerRenderer/PriceMarkerRenderer.swift +10 -6
  31. package/ios/NitroMap.swift +279 -13
  32. package/ios/NitroMapConfig/NitroMapConfig.swift +45 -0
  33. package/ios/Providers/{GoogleMapDelegate.swift → Google/GoogleMapDelegate.swift} +48 -23
  34. package/ios/Providers/Google/GoogleMapProvider+Camera.swift +180 -0
  35. package/ios/Providers/Google/GoogleMapProvider+Clustering.swift +541 -0
  36. package/ios/Providers/Google/GoogleMapProvider+Markers.swift +270 -0
  37. package/ios/Providers/Google/GoogleMapProvider+Overlays.swift +245 -0
  38. package/ios/Providers/Google/GoogleMapProvider+UserLocation.swift +180 -0
  39. package/ios/Providers/Google/GoogleMapProvider.swift +342 -0
  40. package/ios/Providers/MapProviderFactory.swift +17 -0
  41. package/ios/Providers/MapProviderProtocol.swift +48 -1
  42. package/ios/Shared/ClusterConfig+Factory.swift +2 -2
  43. package/ios/Shared/MapStyleProvider.swift +6 -4
  44. package/ios/Shared/MarkerSelectionHandler.swift +4 -1
  45. package/ios/Utils/ColorValueExtension.swift +46 -67
  46. package/lib/module/components/ImageMarker.js +39 -29
  47. package/lib/module/components/ImageMarker.js.map +1 -1
  48. package/lib/module/components/Marker.js +118 -0
  49. package/lib/module/components/Marker.js.map +1 -0
  50. package/lib/module/components/NitroCircle.js +92 -0
  51. package/lib/module/components/NitroCircle.js.map +1 -0
  52. package/lib/module/components/NitroMap.js +216 -76
  53. package/lib/module/components/NitroMap.js.map +1 -1
  54. package/lib/module/components/NitroPolygon.js +137 -0
  55. package/lib/module/components/NitroPolygon.js.map +1 -0
  56. package/lib/module/components/NitroPolyline.js +115 -0
  57. package/lib/module/components/NitroPolyline.js.map +1 -0
  58. package/lib/module/components/PriceMarker.js +16 -29
  59. package/lib/module/components/PriceMarker.js.map +1 -1
  60. package/lib/module/context/NitroMapContext.js.map +1 -1
  61. package/lib/module/hooks/useNitroCircle.js +18 -0
  62. package/lib/module/hooks/useNitroCircle.js.map +1 -0
  63. package/lib/module/hooks/useNitroMarker.js +26 -9
  64. package/lib/module/hooks/useNitroMarker.js.map +1 -1
  65. package/lib/module/hooks/useNitroOverlay.js +59 -0
  66. package/lib/module/hooks/useNitroOverlay.js.map +1 -0
  67. package/lib/module/hooks/useNitroPolygon.js +18 -0
  68. package/lib/module/hooks/useNitroPolygon.js.map +1 -0
  69. package/lib/module/hooks/useNitroPolyline.js +18 -0
  70. package/lib/module/hooks/useNitroPolyline.js.map +1 -0
  71. package/lib/module/index.js +5 -0
  72. package/lib/module/index.js.map +1 -1
  73. package/lib/module/types/overlay.js +4 -0
  74. package/lib/module/types/overlay.js.map +1 -0
  75. package/lib/module/types/theme.js +4 -0
  76. package/lib/module/types/theme.js.map +1 -0
  77. package/lib/module/utils/colors.js +41 -13
  78. package/lib/module/utils/colors.js.map +1 -1
  79. package/lib/module/utils/validation.js +45 -0
  80. package/lib/module/utils/validation.js.map +1 -0
  81. package/lib/typescript/src/components/ImageMarker.d.ts.map +1 -1
  82. package/lib/typescript/src/components/Marker.d.ts +34 -0
  83. package/lib/typescript/src/components/Marker.d.ts.map +1 -0
  84. package/lib/typescript/src/components/NitroCircle.d.ts +70 -0
  85. package/lib/typescript/src/components/NitroCircle.d.ts.map +1 -0
  86. package/lib/typescript/src/components/NitroMap.d.ts +60 -3
  87. package/lib/typescript/src/components/NitroMap.d.ts.map +1 -1
  88. package/lib/typescript/src/components/NitroPolygon.d.ts +86 -0
  89. package/lib/typescript/src/components/NitroPolygon.d.ts.map +1 -0
  90. package/lib/typescript/src/components/NitroPolyline.d.ts +84 -0
  91. package/lib/typescript/src/components/NitroPolyline.d.ts.map +1 -0
  92. package/lib/typescript/src/components/PriceMarker.d.ts +0 -5
  93. package/lib/typescript/src/components/PriceMarker.d.ts.map +1 -1
  94. package/lib/typescript/src/context/NitroMapContext.d.ts +2 -0
  95. package/lib/typescript/src/context/NitroMapContext.d.ts.map +1 -1
  96. package/lib/typescript/src/hooks/useNitroCircle.d.ts +7 -0
  97. package/lib/typescript/src/hooks/useNitroCircle.d.ts.map +1 -0
  98. package/lib/typescript/src/hooks/useNitroMarker.d.ts +20 -0
  99. package/lib/typescript/src/hooks/useNitroMarker.d.ts.map +1 -1
  100. package/lib/typescript/src/hooks/useNitroOverlay.d.ts +26 -0
  101. package/lib/typescript/src/hooks/useNitroOverlay.d.ts.map +1 -0
  102. package/lib/typescript/src/hooks/useNitroPolygon.d.ts +7 -0
  103. package/lib/typescript/src/hooks/useNitroPolygon.d.ts.map +1 -0
  104. package/lib/typescript/src/hooks/useNitroPolyline.d.ts +7 -0
  105. package/lib/typescript/src/hooks/useNitroPolyline.d.ts.map +1 -0
  106. package/lib/typescript/src/index.d.ts +15 -2
  107. package/lib/typescript/src/index.d.ts.map +1 -1
  108. package/lib/typescript/src/specs/NitroMap.nitro.d.ts +248 -6
  109. package/lib/typescript/src/specs/NitroMap.nitro.d.ts.map +1 -1
  110. package/lib/typescript/src/types/map.d.ts +34 -4
  111. package/lib/typescript/src/types/map.d.ts.map +1 -1
  112. package/lib/typescript/src/types/marker.d.ts +24 -36
  113. package/lib/typescript/src/types/marker.d.ts.map +1 -1
  114. package/lib/typescript/src/types/overlay.d.ts +84 -0
  115. package/lib/typescript/src/types/overlay.d.ts.map +1 -0
  116. package/lib/typescript/src/types/theme.d.ts +93 -0
  117. package/lib/typescript/src/types/theme.d.ts.map +1 -0
  118. package/lib/typescript/src/utils/colors.d.ts +6 -8
  119. package/lib/typescript/src/utils/colors.d.ts.map +1 -1
  120. package/lib/typescript/src/utils/validation.d.ts +12 -0
  121. package/lib/typescript/src/utils/validation.d.ts.map +1 -0
  122. package/nitrogen/generated/android/c++/JCircleData.hpp +94 -0
  123. package/nitrogen/generated/android/c++/JClusterConfig.hpp +5 -7
  124. package/nitrogen/generated/android/c++/JCoordinateRing.hpp +77 -0
  125. package/nitrogen/generated/android/c++/JFunc_void_UserLocationChangeEvent.hpp +79 -0
  126. package/nitrogen/generated/android/c++/JFunc_void_UserTrackingMode.hpp +77 -0
  127. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  128. package/nitrogen/generated/android/c++/JHybridNitroMapSpec.cpp +332 -21
  129. package/nitrogen/generated/android/c++/JHybridNitroMapSpec.hpp +53 -2
  130. package/nitrogen/generated/android/c++/JMarkerAnimation.hpp +3 -6
  131. package/nitrogen/generated/android/c++/JMarkerData.hpp +15 -3
  132. package/nitrogen/generated/android/c++/JPolygonData.hpp +133 -0
  133. package/nitrogen/generated/android/c++/JPolylineData.hpp +113 -0
  134. package/nitrogen/generated/android/c++/JUserLocationChangeEvent.hpp +70 -0
  135. package/nitrogen/generated/android/c++/JUserTrackingMode.hpp +62 -0
  136. package/nitrogen/generated/android/c++/views/JHybridNitroMapStateUpdater.cpp +72 -4
  137. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/CircleData.kt +62 -0
  138. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/ClusterConfig.kt +4 -4
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/CoordinateRing.kt +38 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_UserLocationChangeEvent.kt +80 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_UserTrackingMode.kt +80 -0
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_std__string.kt +80 -0
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/HybridNitroMapSpec.kt +228 -2
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/MarkerAnimation.kt +1 -2
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/MarkerData.kt +12 -3
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/PolygonData.kt +62 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/PolylineData.kt +62 -0
  148. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/UserLocationChangeEvent.kt +47 -0
  149. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/{ClusterAnimationStyle.kt → UserTrackingMode.kt} +6 -8
  150. package/nitrogen/generated/android/nitromapOnLoad.cpp +6 -0
  151. package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Bridge.cpp +24 -0
  152. package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Bridge.hpp +178 -17
  153. package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Umbrella.hpp +18 -3
  154. package/nitrogen/generated/ios/c++/HybridNitroMapSpecSwift.hpp +252 -16
  155. package/nitrogen/generated/ios/c++/views/HybridNitroMapComponent.mm +90 -5
  156. package/nitrogen/generated/ios/swift/CircleData.swift +143 -0
  157. package/nitrogen/generated/ios/swift/ClusterConfig.swift +22 -15
  158. package/nitrogen/generated/ios/swift/CoordinateRing.swift +48 -0
  159. package/nitrogen/generated/ios/swift/Func_void_UserLocationChangeEvent.swift +47 -0
  160. package/nitrogen/generated/ios/swift/Func_void_UserTrackingMode.swift +47 -0
  161. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
  162. package/nitrogen/generated/ios/swift/HybridNitroMapSpec.swift +35 -1
  163. package/nitrogen/generated/ios/swift/HybridNitroMapSpec_cxx.swift +582 -8
  164. package/nitrogen/generated/ios/swift/MarkerAnimation.swift +4 -8
  165. package/nitrogen/generated/ios/swift/MarkerData.swift +54 -2
  166. package/nitrogen/generated/ios/swift/PolygonData.swift +167 -0
  167. package/nitrogen/generated/ios/swift/PolylineData.swift +155 -0
  168. package/nitrogen/generated/ios/swift/UserLocationChangeEvent.swift +69 -0
  169. package/nitrogen/generated/ios/swift/UserTrackingMode.swift +44 -0
  170. package/nitrogen/generated/shared/c++/CircleData.hpp +113 -0
  171. package/nitrogen/generated/shared/c++/ClusterConfig.hpp +5 -8
  172. package/nitrogen/generated/shared/c++/CoordinateRing.hpp +77 -0
  173. package/nitrogen/generated/shared/c++/HybridNitroMapSpec.cpp +53 -2
  174. package/nitrogen/generated/shared/c++/HybridNitroMapSpec.hpp +75 -6
  175. package/nitrogen/generated/shared/c++/MarkerAnimation.hpp +4 -8
  176. package/nitrogen/generated/shared/c++/MarkerData.hpp +14 -2
  177. package/nitrogen/generated/shared/c++/PolygonData.hpp +117 -0
  178. package/nitrogen/generated/shared/c++/PolylineData.hpp +114 -0
  179. package/nitrogen/generated/shared/c++/UserLocationChangeEvent.hpp +88 -0
  180. package/nitrogen/generated/shared/c++/UserTrackingMode.hpp +80 -0
  181. package/nitrogen/generated/shared/c++/views/HybridNitroMapComponent.cpp +216 -12
  182. package/nitrogen/generated/shared/c++/views/HybridNitroMapComponent.hpp +23 -1
  183. package/nitrogen/generated/shared/json/NitroMapConfig.json +18 -1
  184. package/package.json +36 -5
  185. package/src/components/ImageMarker.tsx +58 -42
  186. package/src/components/Marker.tsx +161 -0
  187. package/src/components/NitroCircle.tsx +183 -0
  188. package/src/components/NitroMap.tsx +328 -78
  189. package/src/components/NitroPolygon.tsx +229 -0
  190. package/src/components/NitroPolyline.tsx +208 -0
  191. package/src/components/PriceMarker.tsx +23 -48
  192. package/src/context/NitroMapContext.tsx +4 -0
  193. package/src/hooks/useNitroCircle.ts +25 -0
  194. package/src/hooks/useNitroMarker.ts +49 -10
  195. package/src/hooks/useNitroOverlay.ts +68 -0
  196. package/src/hooks/useNitroPolygon.ts +25 -0
  197. package/src/hooks/useNitroPolyline.ts +25 -0
  198. package/src/index.tsx +28 -2
  199. package/src/specs/NitroMap.nitro.ts +294 -5
  200. package/src/types/map.ts +36 -4
  201. package/src/types/marker.ts +24 -44
  202. package/src/types/overlay.ts +87 -0
  203. package/src/types/theme.ts +101 -0
  204. package/src/utils/colors.ts +48 -16
  205. package/src/utils/validation.ts +69 -0
  206. package/android/src/main/java/com/margelo/nitro/nitromap/ClusterIconGenerator.kt +0 -108
  207. package/android/src/main/java/com/margelo/nitro/nitromap/ColorUtils.kt +0 -63
  208. package/android/src/main/java/com/margelo/nitro/nitromap/HybridNitroMap.kt +0 -408
  209. package/android/src/main/java/com/margelo/nitro/nitromap/HybridNitroMapConfig.kt +0 -68
  210. package/android/src/main/java/com/margelo/nitro/nitromap/MarkerIconCache.kt +0 -176
  211. package/android/src/main/java/com/margelo/nitro/nitromap/MarkerIconFactory.kt +0 -252
  212. package/android/src/main/java/com/margelo/nitro/nitromap/clustering/NitroClusterEngine.kt +0 -252
  213. package/android/src/main/java/com/margelo/nitro/nitromap/clustering/QuadTree.kt +0 -195
  214. package/android/src/main/java/com/margelo/nitro/nitromap/providers/GoogleMapProvider.kt +0 -912
  215. package/android/src/main/java/com/margelo/nitro/nitromap/providers/MapProviderInterface.kt +0 -70
  216. package/cpp/QuadTree.hpp +0 -246
  217. package/ios/NitroMapConfig/HybridNitroMapConfig.swift +0 -33
  218. package/ios/Providers/GoogleMapProvider+Camera.swift +0 -164
  219. package/ios/Providers/GoogleMapProvider.swift +0 -924
  220. package/nitrogen/generated/android/c++/JClusterAnimationStyle.hpp +0 -68
  221. package/nitrogen/generated/ios/swift/ClusterAnimationStyle.swift +0 -52
  222. package/nitrogen/generated/shared/c++/ClusterAnimationStyle.hpp +0 -88
@@ -0,0 +1,815 @@
1
+ package com.margelo.nitro.nitromap.providers.google
2
+
3
+ import android.content.Context
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.content.res.Resources
7
+ import android.util.Log
8
+ import android.view.View
9
+ import com.google.android.gms.maps.CameraUpdateFactory
10
+ import com.google.android.gms.maps.GoogleMap
11
+ import com.google.android.gms.maps.model.CameraPosition
12
+ import com.google.android.gms.maps.model.LatLng
13
+ import com.google.android.gms.maps.model.LatLngBounds
14
+ import com.google.android.gms.maps.model.Marker
15
+ import com.google.android.gms.maps.model.Polyline
16
+ import com.google.android.gms.maps.model.Polygon
17
+ import com.google.android.gms.maps.model.Circle
18
+ import com.google.android.gms.maps.model.MapColorScheme
19
+ import com.google.android.gms.maps.model.MapStyleOptions
20
+ import android.os.Bundle
21
+ import com.margelo.nitro.nitromap.*
22
+ import com.margelo.nitro.nitromap.markers.MarkerIconFactory
23
+ import com.margelo.nitro.nitromap.providers.MapProviderInterface
24
+ import kotlinx.coroutines.CoroutineScope
25
+ import kotlinx.coroutines.Dispatchers
26
+ import kotlinx.coroutines.SupervisorJob
27
+ import kotlinx.coroutines.cancel
28
+ import java.util.concurrent.ConcurrentLinkedQueue
29
+ import kotlin.math.abs
30
+ import kotlin.math.ln
31
+ import kotlin.math.pow
32
+ import kotlin.math.roundToInt
33
+
34
+ /**
35
+ * Google Maps implementation of the map provider.
36
+ *
37
+ * The core challenge: GoogleMap is ASYNC — available only after getMapAsync().
38
+ * All operations use withMap {} to queue commands until the map is ready.
39
+ *
40
+ * This class holds all map data (markers, overlays, settings) in Kotlin data structures
41
+ * that survive MapView detach/reattach cycles. Only native GoogleMap objects are recreated.
42
+ */
43
+ class GoogleMapProvider(internal val context: Context) : MapProviderInterface {
44
+
45
+ companion object {
46
+ private const val TAG = "NitroMap"
47
+ private const val DEFAULT_LATITUDE = 41.2995
48
+ private const val DEFAULT_LONGITUDE = 69.2401
49
+ private const val DEFAULT_ZOOM = 10f
50
+ private const val BASE_ALTITUDE = 40_000_000.0 // meters at zoom 0, matches iOS
51
+
52
+ }
53
+
54
+ // MARK: - MapView Lifecycle (absorbed from NitroMap facade)
55
+
56
+ private var mapView: NitroMapView? = null
57
+ private var savedState: Bundle? = null
58
+
59
+ override fun createMapView(context: Context): View {
60
+ val mv = NitroMapView(context)
61
+ mv.onCreate(savedState)
62
+ mv.onStart()
63
+ mv.onResume()
64
+ mapView = mv
65
+ return mv
66
+ }
67
+
68
+ override fun onViewAttached(view: View) {
69
+ val mv = view as NitroMapView
70
+ mv.getMapAsync { googleMap ->
71
+ onMapReady(googleMap, mv)
72
+ }
73
+ }
74
+
75
+ override fun onViewDetaching() {
76
+ mapView?.let { mv ->
77
+ val state = Bundle()
78
+ mv.onSaveInstanceState(state)
79
+ savedState = state
80
+ mv.onPause()
81
+ mv.onStop()
82
+ }
83
+ onMapDetaching()
84
+ mapView = null
85
+ }
86
+
87
+ override fun onViewDestroying() {
88
+ onMapDestroying()
89
+ mapView?.let { mv ->
90
+ mv.onPause()
91
+ mv.onStop()
92
+ mv.onDestroy()
93
+ }
94
+ mapView = null
95
+ savedState = null
96
+ }
97
+
98
+ // MARK: - GoogleMap Reference + Command Queue
99
+
100
+ var googleMap: GoogleMap? = null
101
+ private set
102
+ @Volatile
103
+ private var isMapReady = false
104
+ // C-1: Thread-safe queue — accessed from JS thread (add) and main thread (flush)
105
+ private val pendingCommands = ConcurrentLinkedQueue<(GoogleMap) -> Unit>()
106
+ private val mainHandler = Handler(Looper.getMainLooper())
107
+
108
+ // C-3: Structured coroutine scope — cancelled on destroy to prevent leaks
109
+ val providerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
110
+
111
+ // Icon factory — created lazily on first onMapReady, cleared on destroy
112
+ var iconFactory: MarkerIconFactory? = null
113
+ private set
114
+
115
+ /**
116
+ * Execute a command on GoogleMap, always on the main thread.
117
+ * If map isn't ready, queue it. When onMapReady fires, all queued commands
118
+ * are flushed in order on the main thread.
119
+ *
120
+ * IMPORTANT: GoogleMap API calls MUST run on the main thread.
121
+ * Methods called from JS arrive on a background thread (NativeRunnable),
122
+ * so we always post to mainHandler.
123
+ */
124
+ fun withMap(command: (GoogleMap) -> Unit) {
125
+ val map = googleMap
126
+ if (map != null && isMapReady) {
127
+ if (Looper.myLooper() == Looper.getMainLooper()) {
128
+ command(map)
129
+ } else {
130
+ mainHandler.post { command(map) }
131
+ }
132
+ } else {
133
+ // C-1: ConcurrentLinkedQueue.add is thread-safe
134
+ pendingCommands.add(command)
135
+ }
136
+ }
137
+
138
+ // MARK: - Delegate
139
+
140
+ private var delegate: GoogleMapDelegate? = null
141
+
142
+ // MARK: - State (survives detach/reattach)
143
+
144
+ // Stored prop values — applied to new GoogleMap on reattach.
145
+ // Props with withMap {} setters are live-applied when changed by React.
146
+ // applyAllSettings() bulk-applies all stored values on map ready / reattach.
147
+
148
+ // M-7: Track whether initialRegion has been applied to prevent reapply on reattach
149
+ private var initialRegionApplied = false
150
+
151
+ override var initialRegion: Region? = Region(DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 0.15, 0.15)
152
+
153
+ override var region: Region? = null
154
+ set(value) {
155
+ field = value
156
+ if (value == null) return
157
+ withMap { map ->
158
+ val currentPos = map.cameraPosition.target
159
+ val latDiff = abs(currentPos.latitude - value.latitude)
160
+ val lonDiff = abs(currentPos.longitude - value.longitude)
161
+ if (latDiff < 0.0001 && lonDiff < 0.0001) return@withMap
162
+ val camera = CameraPosition.Builder()
163
+ .target(LatLng(value.latitude, value.longitude))
164
+ .zoom(calculateZoom(value))
165
+ .build()
166
+ map.animateCamera(CameraUpdateFactory.newCameraPosition(camera))
167
+ }
168
+ }
169
+
170
+ override var showsUserLocation: Boolean? = null
171
+
172
+ override var zoomEnabled: Boolean? = null
173
+ set(value) { field = value; withMap { it.uiSettings.isZoomGesturesEnabled = value ?: true } }
174
+
175
+ override var scrollEnabled: Boolean? = null
176
+ set(value) { field = value; withMap { it.uiSettings.isScrollGesturesEnabled = value ?: true } }
177
+
178
+ override var rotateEnabled: Boolean? = null
179
+ set(value) { field = value; withMap { it.uiSettings.isRotateGesturesEnabled = value ?: true } }
180
+
181
+ override var pitchEnabled: Boolean? = null
182
+ set(value) { field = value; withMap { it.uiSettings.isTiltGesturesEnabled = value ?: true } }
183
+
184
+ override var mapType: MapType? = null
185
+ set(value) { field = value; withMap { it.mapType = convertMapType(value) } }
186
+
187
+ override var customMapStyle: Array<MapStyleElement>? = null
188
+ set(value) { field = value; withMap { applyMapStyleInternal(it) } }
189
+
190
+ override var clusterConfig: ClusterConfig? = null
191
+ set(value) { field = value; updateClusterConfig() }
192
+
193
+ override var mapPadding: EdgePadding = EdgePadding(0.0, 0.0, 0.0, 0.0)
194
+ set(value) {
195
+ field = value
196
+ // M-6: Standardize on context.resources.displayMetrics.density
197
+ val density = context.resources.displayMetrics.density
198
+ withMap {
199
+ it.setPadding(
200
+ (value.left * density).roundToInt(),
201
+ (value.top * density).roundToInt(),
202
+ (value.right * density).roundToInt(),
203
+ (value.bottom * density).roundToInt()
204
+ )
205
+ }
206
+ }
207
+
208
+ override var showsTraffic: Boolean? = null
209
+ set(value) { field = value; withMap { it.isTrafficEnabled = value ?: false } }
210
+
211
+ override var showsBuildings: Boolean? = null
212
+ set(value) { field = value; withMap { it.isBuildingsEnabled = value ?: true } }
213
+
214
+ override var showsCompass: Boolean? = null
215
+ set(value) { field = value; withMap { it.uiSettings.isCompassEnabled = value ?: true } }
216
+
217
+ // L-2: Align with iOS default (1.0, not 0.0)
218
+ override var minZoom: Double = 1.0
219
+ set(value) { field = value; withMap { it.setMinZoomPreference(value.toFloat()) } }
220
+
221
+ // L-1: Align with facade default (22.0, not 21.0)
222
+ override var maxZoom: Double = 22.0
223
+ set(value) { field = value; withMap { it.setMaxZoomPreference(value.toFloat()) } }
224
+
225
+ override var darkMode: Boolean? = null
226
+ set(value) { field = value; withMap { applyMapStyleInternal(it) } }
227
+
228
+ override var userTrackingMode: UserTrackingMode? = null
229
+ override var userLocationImage: String = ""
230
+ override var userLocationSize: Double = 40.0
231
+ override var userLocationAnchor: Point? = null
232
+ set(value) {
233
+ field = value
234
+ // Update anchor on existing custom marker (parity with iOS)
235
+ customUserLocationMarker?.setAnchor(
236
+ value?.x?.toFloat() ?: 0.5f,
237
+ value?.y?.toFloat() ?: 0.5f
238
+ )
239
+ }
240
+
241
+ // Marker/overlay data — survives detach, re-applied on reattach
242
+ val markerData = mutableMapOf<String, MarkerData>()
243
+ val polylineData = mutableMapOf<String, PolylineData>()
244
+ val renderedPolylines = mutableMapOf<String, Polyline>()
245
+ val polygonData = mutableMapOf<String, PolygonData>()
246
+ val renderedPolygons = mutableMapOf<String, Polygon>()
247
+ val circleData = mutableMapOf<String, CircleData>()
248
+ val renderedCircles = mutableMapOf<String, Circle>()
249
+
250
+ var selectedMarkerId: String? = null
251
+
252
+ // User location state (used by GoogleMapProvider+UserLocation.kt)
253
+ var lastKnownUserLocation: android.location.Location? = null
254
+ var lastKnownHeading: Float? = null
255
+ var customUserLocationMarker: Marker? = null
256
+ var cachedUserLocationImageUrl: String = ""
257
+ var userLocationImageBitmap: android.graphics.Bitmap? = null
258
+ var userLocationPositionAnimator: android.animation.ValueAnimator? = null
259
+ var userLocationRotationAnimator: android.animation.ValueAnimator? = null
260
+
261
+ /**
262
+ * Set to true while updateUserLocation is animating the camera for follow mode.
263
+ * Cleared in onCameraIdle. When true, clustering is skipped because the
264
+ * zoom level hasn't changed — only the center shifted slightly to follow the user.
265
+ */
266
+ @Volatile var isFollowingUserCamera: Boolean = false
267
+
268
+ /**
269
+ * M-3: Set to true when markers are added/removed. Cleared after clustering runs.
270
+ * When isFollowingUserCamera is true, clustering is only skipped if this is false
271
+ * (i.e., no marker changes since last cluster run).
272
+ */
273
+ @Volatile var clusteringDirty: Boolean = false
274
+ var lastClusteredZoom: Float = -1f
275
+
276
+ // Clustering state (used by GoogleMapProvider+Clustering.kt)
277
+ var clusteringManager: com.margelo.nitro.nitromap.clustering.ClusteringManager? = null
278
+ val renderedClusterMarkers = mutableListOf<Marker>()
279
+ val renderedSingleMarkers = mutableMapOf<String, Marker>()
280
+ val nonClusteredMarkers = mutableMapOf<String, Marker>()
281
+ val hiddenNonClusteredIds = mutableSetOf<String>()
282
+ // H-3: Removed clusterableMarkerData — markerData is now the single source of truth.
283
+ // All markers are fed to the clustering engine; non-clusterable ones also get direct markers.
284
+ var throttleActive = false
285
+ var currentMapView: android.view.View? = null
286
+
287
+ // MARK: - Callbacks (set by NitroMap from JS props)
288
+
289
+ override var onPress: ((MapPressEvent) -> Unit)? = null
290
+ override var onLongPress: ((MapPressEvent) -> Unit)? = null
291
+ override var onMapReadyCallback: (() -> Unit)? = null
292
+ override var onRegionChange: ((RegionChangeEvent) -> Unit)? = null
293
+ override var onRegionChangeComplete: ((RegionChangeEvent) -> Unit)? = null
294
+ override var onMarkerPress: ((MarkerPressEvent) -> Unit)? = null
295
+ override var onMarkerDragStart: ((MarkerDragEvent) -> Unit)? = null
296
+ override var onMarkerDrag: ((MarkerDragEvent) -> Unit)? = null
297
+ override var onMarkerDragEnd: ((MarkerDragEvent) -> Unit)? = null
298
+ override var onClusterPress: ((ClusterPressEvent) -> Unit)? = null
299
+ override var onError: ((MapError) -> Unit)? = null
300
+ override var onMapIdle: (() -> Unit)? = null
301
+ override var onPolylinePress: ((String) -> Unit)? = null
302
+ override var onPolygonPress: ((String) -> Unit)? = null
303
+ override var onCirclePress: ((String) -> Unit)? = null
304
+ override var onUserLocationChange: ((UserLocationChangeEvent) -> Unit)? = null
305
+ override var onUserTrackingModeChange: ((UserTrackingMode) -> Unit)? = null
306
+ override var onUserLocationError: ((MapError) -> Unit)? = null
307
+
308
+ // MARK: - Map Lifecycle
309
+
310
+ fun onMapReady(map: GoogleMap, mapView: View? = null) {
311
+ googleMap = map
312
+ isMapReady = true
313
+ currentMapView = mapView
314
+
315
+ // Create icon factory (holds an LruCache — keep alive across onMapReady calls)
316
+ if (iconFactory == null) {
317
+ // C-3: Pass providerScope so async image loads are cancelled on destroy
318
+ iconFactory = MarkerIconFactory(context, providerScope).also { factory ->
319
+ factory.onIconLoaded = { markerId, icon ->
320
+ // When async URL image loads, update the rendered marker icon in-place
321
+ renderedSingleMarkers[markerId]?.setIcon(icon)
322
+ nonClusteredMarkers[markerId]?.setIcon(icon)
323
+ }
324
+ }
325
+ }
326
+
327
+ // Create clustering manager (keep alive across onMapReady calls)
328
+ if (clusteringManager == null) {
329
+ clusteringManager = com.margelo.nitro.nitromap.clustering.ClusteringManager().also {
330
+ it.setDensity(context.resources.displayMetrics.density)
331
+ it.clusterConfig = clusterConfig
332
+ }
333
+ }
334
+
335
+ // Set up delegate for all listeners
336
+ delegate = GoogleMapDelegate(this)
337
+ delegate?.setupListeners(map)
338
+
339
+ // Apply all stored settings
340
+ applyAllSettings(map)
341
+
342
+ // C-1: Drain thread-safe queue (poll returns null when empty)
343
+ var commandCount = 0
344
+ while (true) {
345
+ val cmd = pendingCommands.poll() ?: break
346
+ cmd(map)
347
+ commandCount++
348
+ }
349
+
350
+ // Fire JS callback
351
+ onMapReadyCallback?.invoke()
352
+
353
+ Log.d(TAG, "GoogleMap ready, flushed $commandCount queued commands")
354
+ }
355
+
356
+ /**
357
+ * Called on view detach — releases GoogleMap ref but keeps data.
358
+ */
359
+ fun onMapDetaching() {
360
+ // Clear native object references (they're invalidated anyway)
361
+ renderedPolylines.clear()
362
+ renderedPolygons.clear()
363
+ renderedCircles.clear()
364
+ renderedClusterMarkers.clear()
365
+ renderedSingleMarkers.clear()
366
+ nonClusteredMarkers.clear()
367
+ hiddenNonClusteredIds.clear()
368
+ customUserLocationMarker = null
369
+
370
+ // Cancel user location animators to prevent updates after detach
371
+ userLocationPositionAnimator?.cancel()
372
+ userLocationRotationAnimator?.cancel()
373
+
374
+ // Drain stale commands — they reference the old GoogleMap instance
375
+ pendingCommands.clear()
376
+
377
+ // Release GoogleMap ref
378
+ googleMap = null
379
+ isMapReady = false
380
+ delegate = null
381
+ currentMapView = null
382
+
383
+ Log.d(TAG, "Map detaching — GoogleMap ref released, data preserved")
384
+ }
385
+
386
+ /**
387
+ * Called on dispose() — full cleanup.
388
+ */
389
+ fun onMapDestroying() {
390
+ onMapDetaching()
391
+
392
+ // M-4: Cancel user location animators to prevent leaks
393
+ userLocationPositionAnimator?.cancel()
394
+ userLocationPositionAnimator = null
395
+ userLocationRotationAnimator?.cancel()
396
+ userLocationRotationAnimator = null
397
+
398
+ // C-3: Cancel all coroutines to prevent leaks
399
+ providerScope.cancel()
400
+
401
+ // Clear all data
402
+ markerData.clear()
403
+ polylineData.clear()
404
+ polygonData.clear()
405
+ circleData.clear()
406
+ pendingCommands.clear()
407
+ iconFactory?.clearCache()
408
+ iconFactory = null
409
+ clusteringManager?.destroy()
410
+ clusteringManager = null
411
+
412
+ // Null all callbacks to prevent leaks
413
+ onPress = null
414
+ onLongPress = null
415
+ onMapReadyCallback = null
416
+ onRegionChange = null
417
+ onRegionChangeComplete = null
418
+ onMarkerPress = null
419
+ onMarkerDragStart = null
420
+ onMarkerDrag = null
421
+ onMarkerDragEnd = null
422
+ onClusterPress = null
423
+ onError = null
424
+ onMapIdle = null
425
+ onPolylinePress = null
426
+ onPolygonPress = null
427
+ onCirclePress = null
428
+ onUserLocationChange = null
429
+ onUserTrackingModeChange = null
430
+ onUserLocationError = null
431
+
432
+ Log.d(TAG, "Map destroying — full cleanup complete")
433
+ }
434
+
435
+ // MARK: - Apply Settings
436
+
437
+ /**
438
+ * Applies all currently stored prop values to a (possibly new) GoogleMap instance.
439
+ * Called after onMapReady and after view reattach.
440
+ */
441
+ private fun applyAllSettings(map: GoogleMap) {
442
+ // UiSettings
443
+ map.uiSettings.isZoomGesturesEnabled = zoomEnabled ?: true
444
+ map.uiSettings.isScrollGesturesEnabled = scrollEnabled ?: true
445
+ map.uiSettings.isRotateGesturesEnabled = rotateEnabled ?: true
446
+ map.uiSettings.isTiltGesturesEnabled = pitchEnabled ?: true
447
+ map.uiSettings.isCompassEnabled = showsCompass ?: true
448
+ map.uiSettings.isMyLocationButtonEnabled = false // Always hidden
449
+
450
+ // Map type
451
+ map.mapType = convertMapType(mapType)
452
+
453
+ // Zoom limits
454
+ map.setMinZoomPreference(minZoom.toFloat())
455
+ map.setMaxZoomPreference(maxZoom.toFloat())
456
+
457
+ // Traffic + buildings
458
+ map.isTrafficEnabled = showsTraffic ?: false
459
+ map.isBuildingsEnabled = showsBuildings ?: true
460
+
461
+ // User location — show native blue dot when no custom image is set
462
+ if (userLocationImage.isEmpty()) {
463
+ try {
464
+ map.isMyLocationEnabled = showsUserLocation ?: false
465
+ } catch (e: SecurityException) {
466
+ Log.w(TAG, "Location permission not granted")
467
+ }
468
+ }
469
+
470
+ // H-4: mapPadding is non-nullable EdgePadding — no null check needed
471
+ // M-6: Standardize on context.resources.displayMetrics.density
472
+ val density = context.resources.displayMetrics.density
473
+ map.setPadding(
474
+ (mapPadding.left * density).roundToInt(),
475
+ (mapPadding.top * density).roundToInt(),
476
+ (mapPadding.right * density).roundToInt(),
477
+ (mapPadding.bottom * density).roundToInt()
478
+ )
479
+
480
+ // Camera — use region if set, otherwise initialRegion (only on first apply)
481
+ val targetRegion = if (region != null) {
482
+ region
483
+ } else if (!initialRegionApplied) {
484
+ // M-7: Only apply initialRegion once (matches iOS guard oldValue == nil)
485
+ initialRegionApplied = true
486
+ initialRegion
487
+ } else {
488
+ null
489
+ }
490
+ if (targetRegion != null) {
491
+ val camera = CameraPosition.Builder()
492
+ .target(LatLng(targetRegion.latitude, targetRegion.longitude))
493
+ .zoom(calculateZoom(targetRegion))
494
+ .build()
495
+ map.moveCamera(CameraUpdateFactory.newCameraPosition(camera))
496
+ }
497
+
498
+ // Map style (custom style takes priority over dark mode)
499
+ applyMapStyleInternal(map)
500
+
501
+ // Re-apply markers (after detach/reattach, native Marker objects are gone)
502
+ reapplyMarkers()
503
+
504
+ // Re-apply overlays (polylines, polygons, circles)
505
+ reapplyOverlays()
506
+ }
507
+
508
+ // MARK: - Map Style Helpers
509
+
510
+ /**
511
+ * Applies the current map style to the GoogleMap.
512
+ * Priority: customMapStyle > darkMode > default (null).
513
+ */
514
+ private fun applyMapStyleInternal(map: GoogleMap) {
515
+ // Custom style takes priority
516
+ val styleElements = customMapStyle
517
+ if (styleElements != null && styleElements.isNotEmpty()) {
518
+ try {
519
+ val jsonString = mapStyleElementsToJson(styleElements)
520
+ val success = map.setMapStyle(MapStyleOptions(jsonString))
521
+ if (!success) {
522
+ Log.w(TAG, "Map style parsing failed")
523
+ onError?.invoke(MapError("STYLE_ERROR", "Map style parsing failed"))
524
+ }
525
+ return
526
+ } catch (e: Exception) {
527
+ Log.e(TAG, "Failed to apply custom map style", e)
528
+ onError?.invoke(MapError("STYLE_ERROR", "Failed to apply map style: ${e.message}"))
529
+ }
530
+ }
531
+
532
+ // Dark mode fallback — use native MapColorScheme (SDK 19.0.0+)
533
+ if (darkMode == true) {
534
+ map.setMapStyle(null) // clear any custom JSON style
535
+ map.setMapColorScheme(MapColorScheme.DARK)
536
+ return
537
+ }
538
+
539
+ // Clear style — reset to light
540
+ map.setMapStyle(null)
541
+ map.setMapColorScheme(MapColorScheme.LIGHT)
542
+ }
543
+
544
+ private fun mapStyleElementsToJson(elements: Array<MapStyleElement>): String {
545
+ val sb = StringBuilder("[")
546
+ elements.forEachIndexed { index, element ->
547
+ if (index > 0) sb.append(",")
548
+ sb.append("{")
549
+ val parts = mutableListOf<String>()
550
+ element.featureType?.let { parts.add("\"featureType\":\"$it\"") }
551
+ element.elementType?.let { parts.add("\"elementType\":\"$it\"") }
552
+
553
+ val stylerParts = element.stylers.map { styler ->
554
+ val s = mutableListOf<String>()
555
+ styler.color?.let { s.add("\"color\":\"$it\"") }
556
+ styler.visibility?.let { s.add("\"visibility\":\"$it\"") }
557
+ styler.weight?.let { s.add("\"weight\":$it") }
558
+ styler.saturation?.let { s.add("\"saturation\":$it") }
559
+ styler.lightness?.let { s.add("\"lightness\":$it") }
560
+ styler.gamma?.let { s.add("\"gamma\":$it") }
561
+ "{${s.joinToString(",")}}"
562
+ }
563
+ parts.add("\"stylers\":[${stylerParts.joinToString(",")}]")
564
+
565
+ sb.append(parts.joinToString(","))
566
+ sb.append("}")
567
+ }
568
+ sb.append("]")
569
+ return sb.toString()
570
+ }
571
+
572
+ // MARK: - Interface Method Delegations
573
+ // These delegate to internal extension functions defined in separate files,
574
+ // allowing the extension file organization while satisfying the interface contract.
575
+
576
+ // Markers (delegated to GoogleMapProvider+Markers.kt)
577
+ override fun addMarker(marker: MarkerData) = addMarkerInternal(marker)
578
+ override fun addMarkers(markers: Array<MarkerData>) = addMarkersInternal(markers)
579
+ override fun updateMarker(marker: MarkerData) = updateMarkerInternal(marker)
580
+ override fun removeMarker(id: String) = removeMarkerInternal(id)
581
+ override fun clearMarkers() = clearMarkersInternal()
582
+ override fun selectMarker(id: String) = selectMarkerInternal(id)
583
+ override fun deselectMarker() = deselectMarkerInternal()
584
+
585
+ // Overlays (delegated to GoogleMapProvider+Overlays.kt)
586
+ override fun addPolyline(polyline: PolylineData) = addPolylineInternal(polyline)
587
+ override fun updatePolyline(polyline: PolylineData) = updatePolylineInternal(polyline)
588
+ override fun removePolyline(id: String) = removePolylineInternal(id)
589
+ override fun clearPolylines() = clearPolylinesInternal()
590
+ override fun addPolygon(polygon: PolygonData) = addPolygonInternal(polygon)
591
+ override fun updatePolygon(polygon: PolygonData) = updatePolygonInternal(polygon)
592
+ override fun removePolygon(id: String) = removePolygonInternal(id)
593
+ override fun clearPolygons() = clearPolygonsInternal()
594
+ override fun addCircle(circle: CircleData) = addCircleInternal(circle)
595
+ override fun updateCircle(circle: CircleData) = updateCircleInternal(circle)
596
+ override fun removeCircle(id: String) = removeCircleInternal(id)
597
+ override fun clearCircles() = clearCirclesInternal()
598
+
599
+ // Clustering (delegated to GoogleMapProvider+Clustering.kt)
600
+ override fun setClusteringEnabled(enabled: Boolean) = setClusteringEnabledInternal(enabled)
601
+ override fun refreshClusters() = refreshClustersInternal()
602
+
603
+ // User Location (delegated to GoogleMapProvider+UserLocation.kt)
604
+ override fun updateUserLocation(location: android.location.Location, heading: Float?) = updateUserLocationInternal(location, heading)
605
+ override fun setupCustomUserLocationMarker(context: Context) = setupCustomUserLocationMarkerInternal(context)
606
+ override fun centerOnUserLocation() = centerOnUserLocationInternal()
607
+
608
+ // MARK: - User Location Disable
609
+
610
+ override fun disableUserLocation() {
611
+ customUserLocationMarker?.remove()
612
+ customUserLocationMarker = null
613
+ withMap { map ->
614
+ try { map.isMyLocationEnabled = false } catch (_: SecurityException) {}
615
+ }
616
+ }
617
+
618
+ // MARK: - Camera Methods (matches iOS GoogleMapProvider+Camera.swift)
619
+
620
+ override fun animateToRegion(region: Region, duration: Double?) {
621
+ withMap { map ->
622
+ val camera = CameraPosition.Builder()
623
+ .target(LatLng(region.latitude, region.longitude))
624
+ .zoom(calculateZoom(region))
625
+ .build()
626
+ // L-6: Default to 500ms to match spec (iOS uses 500ms)
627
+ map.animateCamera(
628
+ CameraUpdateFactory.newCameraPosition(camera),
629
+ (duration?.toInt() ?: 500),
630
+ null
631
+ )
632
+ }
633
+ }
634
+
635
+ override fun fitToCoordinates(coordinates: Array<Coordinate>, edgePadding: EdgePadding?, animated: Boolean?) {
636
+ if (coordinates.isEmpty()) return
637
+
638
+ if (coordinates.size == 1) {
639
+ val first = coordinates[0]
640
+ animateToRegion(
641
+ Region(first.latitude, first.longitude, 0.05, 0.05),
642
+ if (animated != false) 300.0 else 0.0
643
+ )
644
+ return
645
+ }
646
+
647
+ withMap { map ->
648
+ val builder = LatLngBounds.Builder()
649
+ coordinates.forEach {
650
+ builder.include(LatLng(it.latitude, it.longitude))
651
+ }
652
+ val bounds = builder.build()
653
+
654
+ // Per-edge padding via temporary setPadding + restore
655
+ // M-6: Standardize on context.resources.displayMetrics.density
656
+ val density = context.resources.displayMetrics.density
657
+ val topPx = ((edgePadding?.top ?: 50.0) * density).toInt()
658
+ val leftPx = ((edgePadding?.left ?: 50.0) * density).toInt()
659
+ val bottomPx = ((edgePadding?.bottom ?: 50.0) * density).toInt()
660
+ val rightPx = ((edgePadding?.right ?: 50.0) * density).toInt()
661
+
662
+ // Save current padding, apply fit padding, then restore
663
+ // H-4: mapPadding is non-nullable — no null check needed
664
+ map.setPadding(leftPx, topPx, rightPx, bottomPx)
665
+ val update = CameraUpdateFactory.newLatLngBounds(bounds, 0)
666
+
667
+ val restorePadding = {
668
+ map.setPadding(
669
+ (mapPadding.left * density).roundToInt(),
670
+ (mapPadding.top * density).roundToInt(),
671
+ (mapPadding.right * density).roundToInt(),
672
+ (mapPadding.bottom * density).roundToInt()
673
+ )
674
+ }
675
+
676
+ if (animated != false) {
677
+ map.animateCamera(update, object : GoogleMap.CancelableCallback {
678
+ override fun onFinish() { restorePadding() }
679
+ override fun onCancel() { restorePadding() }
680
+ })
681
+ } else {
682
+ map.moveCamera(update)
683
+ restorePadding()
684
+ }
685
+ }
686
+ }
687
+
688
+ override fun fitToSuppliedMarkers(markerIds: Array<String>, edgePadding: EdgePadding?, animated: Boolean?) {
689
+ val coords = markerIds.mapNotNull { id ->
690
+ markerData[id]?.coordinate
691
+ }.toTypedArray()
692
+ if (coords.isEmpty()) return
693
+ fitToCoordinates(coords, edgePadding, animated)
694
+ }
695
+
696
+ override fun animateCamera(camera: Camera, duration: Double?) {
697
+ withMap { map ->
698
+ val cameraPos = CameraPosition.Builder()
699
+ .target(LatLng(camera.center.latitude, camera.center.longitude))
700
+ .zoom(camera.zoom.toFloat())
701
+ .bearing(camera.heading.toFloat())
702
+ .tilt(camera.pitch.toFloat())
703
+ .build()
704
+ map.animateCamera(
705
+ CameraUpdateFactory.newCameraPosition(cameraPos),
706
+ (duration?.toInt() ?: 300),
707
+ null
708
+ )
709
+ }
710
+ }
711
+
712
+ override fun setCamera(camera: Camera) {
713
+ withMap { map ->
714
+ val cameraPos = CameraPosition.Builder()
715
+ .target(LatLng(camera.center.latitude, camera.center.longitude))
716
+ .zoom(camera.zoom.toFloat())
717
+ .bearing(camera.heading.toFloat())
718
+ .tilt(camera.pitch.toFloat())
719
+ .build()
720
+ map.moveCamera(CameraUpdateFactory.newCameraPosition(cameraPos))
721
+ }
722
+ }
723
+
724
+ override fun getCamera(): Camera {
725
+ val map = googleMap
726
+ return if (map != null) {
727
+ val pos = map.cameraPosition
728
+ Camera(
729
+ center = Coordinate(pos.target.latitude, pos.target.longitude),
730
+ pitch = pos.tilt.toDouble(),
731
+ heading = pos.bearing.toDouble(),
732
+ altitude = altitudeFromZoom(pos.zoom.toDouble()),
733
+ zoom = pos.zoom.toDouble()
734
+ )
735
+ } else {
736
+ Camera(
737
+ center = Coordinate(0.0, 0.0),
738
+ pitch = 0.0,
739
+ heading = 0.0,
740
+ altitude = 0.0,
741
+ zoom = 0.0
742
+ )
743
+ }
744
+ }
745
+
746
+ override fun getMapBoundaries(): MapBoundaries? {
747
+ val map = googleMap ?: return null
748
+
749
+ val bounds = map.projection.visibleRegion.latLngBounds
750
+ return MapBoundaries(
751
+ northEast = Coordinate(bounds.northeast.latitude, bounds.northeast.longitude),
752
+ southWest = Coordinate(bounds.southwest.latitude, bounds.southwest.longitude)
753
+ )
754
+ }
755
+
756
+ override fun pointForCoordinate(coordinate: Coordinate): Point {
757
+ val map = googleMap ?: return Point(0.0, 0.0)
758
+ val screenPoint = map.projection.toScreenLocation(
759
+ LatLng(coordinate.latitude, coordinate.longitude)
760
+ )
761
+ return Point(screenPoint.x.toDouble(), screenPoint.y.toDouble())
762
+ }
763
+
764
+ override fun coordinateForPoint(point: Point): Coordinate {
765
+ val map = googleMap ?: return Coordinate(0.0, 0.0)
766
+ val latLng = map.projection.fromScreenLocation(
767
+ android.graphics.Point(point.x.toInt(), point.y.toInt())
768
+ )
769
+ return Coordinate(latLng.latitude, latLng.longitude)
770
+ }
771
+
772
+ // MARK: - Helper Methods
773
+
774
+ fun getCurrentRegion(): Region {
775
+ val map = googleMap ?: return Region(DEFAULT_LATITUDE, DEFAULT_LONGITUDE, 0.15, 0.15)
776
+
777
+ val bounds = map.projection.visibleRegion.latLngBounds
778
+ val center = map.cameraPosition.target
779
+
780
+ return Region(
781
+ latitude = center.latitude,
782
+ longitude = center.longitude,
783
+ latitudeDelta = bounds.northeast.latitude - bounds.southwest.latitude,
784
+ longitudeDelta = bounds.northeast.longitude - bounds.southwest.longitude
785
+ )
786
+ }
787
+
788
+ fun calculateZoom(region: Region): Float {
789
+ val latDelta = region.latitudeDelta
790
+ val lonDelta = region.longitudeDelta
791
+ val delta = maxOf(latDelta, lonDelta)
792
+ if (delta <= 0) return DEFAULT_ZOOM
793
+
794
+ // Same formula as iOS MapStyleProvider.zoomFromDeltas
795
+ val zoom = ln(360.0 / delta) / ln(2.0)
796
+ return zoom.coerceIn(minZoom, maxZoom).toFloat()
797
+ }
798
+
799
+ /**
800
+ * Converts zoom level to approximate altitude in meters.
801
+ * Matches iOS MapStyleProvider.altitudeFromZoom.
802
+ */
803
+ fun altitudeFromZoom(zoom: Double): Double {
804
+ val clamped = zoom.coerceIn(0.0, 21.0)
805
+ return BASE_ALTITUDE / 2.0.pow(clamped)
806
+ }
807
+
808
+ private fun convertMapType(type: MapType?): Int {
809
+ return when (type) {
810
+ MapType.SATELLITE -> GoogleMap.MAP_TYPE_SATELLITE
811
+ MapType.HYBRID -> GoogleMap.MAP_TYPE_HYBRID
812
+ MapType.STANDARD, null -> GoogleMap.MAP_TYPE_NORMAL
813
+ }
814
+ }
815
+ }