@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,524 @@
1
+ package com.margelo.nitro.nitromap.providers.google
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.util.Log
6
+ import com.google.android.gms.maps.model.BitmapDescriptorFactory
7
+ import com.google.android.gms.maps.model.LatLng
8
+ import com.google.android.gms.maps.model.LatLngBounds
9
+ import com.google.android.gms.maps.model.Marker
10
+ import com.google.android.gms.maps.model.MarkerOptions
11
+ import com.margelo.nitro.nitromap.*
12
+ import com.margelo.nitro.nitromap.clustering.ClusteringManager
13
+ import com.margelo.nitro.nitromap.clustering.NitroClusterEngine
14
+ import kotlin.math.*
15
+
16
+ /**
17
+ * Clustering pipeline for GoogleMapProvider.
18
+ * Mirrors iOS GoogleMapProvider+Clustering.swift (~445 lines).
19
+ *
20
+ * Pipeline:
21
+ * 1. Query visible region + renderBuffer expansion
22
+ * 2. Call ClusteringManager.cluster() → C++ Supercluster result
23
+ * 3. Build render items (clusters + singles)
24
+ * 4. Apply strategy (supercluster vs hideOnOverlap)
25
+ * 5. Render new markers BEFORE removing old ones (prevents flicker)
26
+ * 6. Handle selected marker exclusion from clusters
27
+ */
28
+
29
+ // ============================================================================
30
+ // MARK: - Render Item (internal to clustering pipeline)
31
+ // ============================================================================
32
+
33
+ private data class RenderItem(
34
+ val latitude: Double,
35
+ val longitude: Double,
36
+ val kind: RenderKind,
37
+ val iconWidth: Double,
38
+ val iconHeight: Double,
39
+ val priority: Int // Higher = more important (clusters > singles)
40
+ )
41
+
42
+ private sealed class RenderKind {
43
+ data class Cluster(
44
+ val markerIds: List<String>,
45
+ val count: Int,
46
+ val iconSize: Double
47
+ ) : RenderKind()
48
+
49
+ data class Single(val markerId: String) : RenderKind()
50
+ }
51
+
52
+ // ============================================================================
53
+ // MARK: - Clustering Pipeline
54
+ // ============================================================================
55
+
56
+ /** Propagate config to ClusteringManager + re-cluster (mirrors iOS updateClusterConfig) */
57
+ fun GoogleMapProvider.updateClusterConfig() {
58
+ clusteringManager?.clusterConfig = clusterConfig
59
+ performClustering()
60
+ }
61
+
62
+ /** Debounced clustering — fires after 100ms of silence */
63
+ fun GoogleMapProvider.performClustering() {
64
+ clusteringManager?.debounce {
65
+ performClusteringImmediate()
66
+ }
67
+ }
68
+
69
+ /** Throttled clustering — fires at most once per interval during gestures */
70
+ fun GoogleMapProvider.performClusteringThrottled() {
71
+ if (throttleActive) return
72
+
73
+ performClusteringImmediate()
74
+
75
+ val intervalMs = (clusterConfig?.throttleInterval ?: 150.0).toLong()
76
+ throttleActive = true
77
+ Handler(Looper.getMainLooper()).postDelayed({
78
+ throttleActive = false
79
+ }, intervalMs)
80
+ }
81
+
82
+ /** Full clustering pipeline */
83
+ fun GoogleMapProvider.performClusteringImmediate() {
84
+ val map = googleMap ?: return
85
+ val manager = clusteringManager ?: return
86
+ val mapView = currentMapView ?: return
87
+
88
+ val width = mapView.width.toDouble()
89
+ val height = mapView.height.toDouble()
90
+ if (width <= 0 || height <= 0) return
91
+
92
+ val enabled = clusterConfig?.enabled ?: true
93
+ if (!enabled) {
94
+ clearRenderedClusterMarkers()
95
+ renderAllMarkersIndividually()
96
+ return
97
+ }
98
+
99
+ // --- Phase 1: Query region with renderBuffer expansion ---
100
+ val visibleRegion = map.projection.visibleRegion
101
+ val bounds = LatLngBounds.builder()
102
+ .include(visibleRegion.nearLeft)
103
+ .include(visibleRegion.nearRight)
104
+ .include(visibleRegion.farLeft)
105
+ .include(visibleRegion.farRight)
106
+ .build()
107
+
108
+ val zoom = map.cameraPosition.zoom.toDouble()
109
+ val padding = clusterConfig?.renderBuffer ?: 0.0
110
+ val latSpan = bounds.northeast.latitude - bounds.southwest.latitude
111
+ val lonSpan = bounds.northeast.longitude - bounds.southwest.longitude
112
+
113
+ val queryMinLat = bounds.southwest.latitude - latSpan * padding
114
+ val queryMaxLat = bounds.northeast.latitude + latSpan * padding
115
+ val queryMinLon = bounds.southwest.longitude - lonSpan * padding
116
+ val queryMaxLon = bounds.northeast.longitude + lonSpan * padding
117
+
118
+ val result = manager.cluster(queryMinLat, queryMaxLat, queryMinLon, queryMaxLon, zoom, width, height)
119
+ val minClusterSize = (clusterConfig?.minimumClusterSize ?: 2.0).toInt()
120
+ val strategy = clusterConfig?.strategy ?: ClusterStrategy.SUPERCLUSTER
121
+
122
+ // Build the actual visible polygon from the 4 corners of the rotated viewport.
123
+ // The C++ engine returns markers within the axis-aligned bounding box, which is
124
+ // larger than the actual viewport when the map is rotated (bearing ≠ 0).
125
+ // We filter results against this polygon to ensure markers outside the rotated
126
+ // viewport are properly excluded.
127
+ val bearing = map.cameraPosition.bearing
128
+ val isRotated = bearing % 360.0 != 0.0
129
+
130
+ // Expand the visible polygon outward by renderBuffer so that markers near the
131
+ // screen edge (whose icons overlap onto the visible area) are not clipped early.
132
+ val visiblePolygon = if (isRotated) {
133
+ expandPolygon(
134
+ listOf(
135
+ visibleRegion.nearLeft,
136
+ visibleRegion.nearRight,
137
+ visibleRegion.farRight,
138
+ visibleRegion.farLeft
139
+ ),
140
+ padding
141
+ )
142
+ } else null // No polygon filter needed at 0° — axis-aligned bounds are exact
143
+
144
+ // --- Phase 2: Build renderable items ---
145
+ val renderItems = mutableListOf<RenderItem>()
146
+
147
+ for (cluster in result.clusters) {
148
+ // When rotated, filter out clusters outside the actual visible polygon
149
+ if (visiblePolygon != null && !pointInConvexQuad(cluster.latitude, cluster.longitude, visiblePolygon)) continue
150
+
151
+ val containsSelected = selectedMarkerId != null && cluster.markerIds.contains(selectedMarkerId)
152
+ val adjustedCount = if (containsSelected) cluster.count - 1 else cluster.count
153
+ if (adjustedCount < minClusterSize) continue
154
+
155
+ val adjustedIds = if (containsSelected) cluster.markerIds.filter { it != selectedMarkerId } else cluster.markerIds
156
+ val iconDiameter = cluster.iconSize
157
+
158
+ renderItems.add(RenderItem(
159
+ latitude = cluster.latitude,
160
+ longitude = cluster.longitude,
161
+ kind = RenderKind.Cluster(adjustedIds, adjustedCount, iconDiameter),
162
+ iconWidth = iconDiameter,
163
+ iconHeight = iconDiameter,
164
+ priority = adjustedCount + 10000
165
+ ))
166
+ }
167
+
168
+ // L-3: Use density-aware marker dimensions instead of hardcoded 27x43
169
+ val density = context.resources.displayMetrics.density
170
+ val defaultIconW = 27.0 * density // Google Maps default marker width in px
171
+ val defaultIconH = 43.0 * density // Google Maps default marker height in px
172
+
173
+ for (single in result.singleMarkers) {
174
+ // When rotated, filter out singles outside the actual visible polygon
175
+ if (visiblePolygon != null && !pointInConvexQuad(single.latitude, single.longitude, visiblePolygon)) continue
176
+
177
+ if (single.markerId == selectedMarkerId) continue
178
+ if (nonClusteredMarkers.containsKey(single.markerId)) continue
179
+ // H-3: Use markerData (single source of truth) instead of clusterableMarkerData
180
+ val data = markerData[single.markerId] ?: continue
181
+ if (!data.clusteringEnabled) continue // Non-clusterable markers rendered directly
182
+
183
+ renderItems.add(RenderItem(
184
+ latitude = single.latitude,
185
+ longitude = single.longitude,
186
+ kind = RenderKind.Single(single.markerId),
187
+ iconWidth = defaultIconW,
188
+ iconHeight = defaultIconH,
189
+ priority = 0
190
+ ))
191
+ }
192
+
193
+ // --- Phase 2b: Apply strategy ---
194
+ val visibleItems = if (strategy == ClusterStrategy.HIDEONOVERLAP) {
195
+ resolveOverlaps(renderItems, zoom, width)
196
+ } else {
197
+ renderItems
198
+ }
199
+
200
+ // --- Phase 3: Render — add new BEFORE removing old (prevents flicker) ---
201
+ val isFirstRender = renderedClusterMarkers.isEmpty() && renderedSingleMarkers.isEmpty()
202
+ val oldClusterMarkers = renderedClusterMarkers.toList()
203
+ val oldSingleMarkers = renderedSingleMarkers.toMap()
204
+ renderedClusterMarkers.clear()
205
+ renderedSingleMarkers.clear()
206
+
207
+ // Preserve selected marker
208
+ selectedMarkerId?.let { selId ->
209
+ oldSingleMarkers[selId]?.let { selMarker ->
210
+ renderedSingleMarkers[selId] = selMarker
211
+ }
212
+ }
213
+
214
+ // Restore hidden non-clustered markers
215
+ for (id in hiddenNonClusteredIds) {
216
+ nonClusteredMarkers[id]?.isVisible = true
217
+ }
218
+ hiddenNonClusteredIds.clear()
219
+
220
+ // Render new items
221
+ for ((renderIndex, item) in visibleItems.withIndex()) {
222
+ when (val kind = item.kind) {
223
+ is RenderKind.Cluster -> {
224
+ val icon = manager.clusterIcon(kind.count)
225
+ val opts = MarkerOptions()
226
+ .position(LatLng(item.latitude, item.longitude))
227
+ .anchor(0.5f, 0.5f)
228
+ .zIndex((kind.count + 1000).toFloat())
229
+
230
+ icon?.let { opts.icon(BitmapDescriptorFactory.fromBitmap(it)) }
231
+
232
+ val marker = map.addMarker(opts)
233
+ if (marker != null) {
234
+ marker.tag = MarkerTagData.Cluster(kind.markerIds, kind.count)
235
+ renderedClusterMarkers.add(marker)
236
+ // Animate cluster icons when animatesClusters is enabled.
237
+ // Only animate when cluster structure actually changes:
238
+ // - First render, zoom level change, or markers added/removed.
239
+ // Skip on pure pan at same zoom (same clusters, just repositioned).
240
+ val zoomChanged = Math.floor(zoom) != Math.floor(lastClusteredZoom.toDouble())
241
+ val structureChanged = isFirstRender || zoomChanged || clusteringDirty
242
+ val animateOnReappear = clusterConfig?.animateOnReappear ?: true
243
+ val animEnabled = (clusterConfig?.animatesClusters ?: true)
244
+ && (isFirstRender || (animateOnReappear && structureChanged))
245
+ if (animEnabled) {
246
+ val duration = clusterConfig?.animationDuration ?: 0.3
247
+ applyMarkerAnimation(marker, MarkerAnimation.APPEAR, duration)
248
+ }
249
+ }
250
+ }
251
+
252
+ is RenderKind.Single -> {
253
+ // H-3: Use markerData (single source of truth)
254
+ val data = markerData[kind.markerId] ?: continue
255
+
256
+ val existing = oldSingleMarkers[kind.markerId]
257
+ if (existing != null) {
258
+ // Reuse existing marker — skip full property update.
259
+ // The marker already has correct props from updateMarkerInPlace().
260
+ // Only zIndex (render order) changes between clustering cycles.
261
+ // Re-setting icon was causing flicker on every camera idle
262
+ // (e.g. during user location tracking).
263
+ existing.zIndex = renderIndex.toFloat()
264
+ renderedSingleMarkers[kind.markerId] = existing
265
+ } else {
266
+ val opts = buildMarkerOptions(data)
267
+ opts.zIndex(renderIndex.toFloat())
268
+ val marker = map.addMarker(opts)
269
+ if (marker != null) {
270
+ marker.tag = MarkerTagData.Regular(kind.markerId)
271
+ renderedSingleMarkers[kind.markerId] = marker
272
+ // Animate new markers emerging from clusters (matches iOS behavior)
273
+ if (data.animateOnReappear && data.animation != MarkerAnimation.NONE) {
274
+ applyMarkerAnimation(marker, data.animation, data.animationDuration)
275
+ }
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ // Remove old markers AFTER new ones are visible
283
+ for (marker in oldClusterMarkers) {
284
+ marker.remove()
285
+ }
286
+ for ((id, marker) in oldSingleMarkers) {
287
+ if (renderedSingleMarkers.containsKey(id)) continue
288
+ if (id == selectedMarkerId) continue
289
+ marker.remove()
290
+ }
291
+
292
+ // Handle non-clustered markers with hideOnOverlap
293
+ if (strategy == ClusterStrategy.HIDEONOVERLAP) {
294
+ hideOverlappingNonClusteredMarkers(visibleItems, zoom, width)
295
+ }
296
+
297
+ // Track state for next cycle's animation decisions
298
+ lastClusteredZoom = zoom.toFloat()
299
+ clusteringDirty = false
300
+ }
301
+
302
+ // ============================================================================
303
+ // MARK: - Overlap Resolution (hideOnOverlap strategy)
304
+ // ============================================================================
305
+
306
+ private fun resolveOverlaps(
307
+ items: List<RenderItem>,
308
+ zoom: Double,
309
+ mapWidth: Double
310
+ ): List<RenderItem> {
311
+ val sorted = items.sortedByDescending { it.priority }
312
+ val visible = mutableListOf<RenderItem>()
313
+ val worldSize = 256.0 * 2.0.pow(zoom)
314
+
315
+ for (item in sorted) {
316
+ var hasOverlap = false
317
+ for (existing in visible) {
318
+ if (itemsOverlap(item, existing, worldSize)) {
319
+ hasOverlap = true
320
+ break
321
+ }
322
+ }
323
+ if (!hasOverlap) {
324
+ visible.add(item)
325
+ }
326
+ }
327
+ return visible
328
+ }
329
+
330
+ private fun GoogleMapProvider.hideOverlappingNonClusteredMarkers(
331
+ visibleItems: List<RenderItem>,
332
+ zoom: Double,
333
+ mapWidth: Double
334
+ ) {
335
+ val worldSize = 256.0 * 2.0.pow(zoom)
336
+
337
+ for ((id, ncMarker) in nonClusteredMarkers) {
338
+ val ncItem = RenderItem(
339
+ latitude = ncMarker.position.latitude,
340
+ longitude = ncMarker.position.longitude,
341
+ kind = RenderKind.Single(id),
342
+ iconWidth = 44.0,
343
+ iconHeight = 44.0,
344
+ priority = 0
345
+ )
346
+
347
+ var hasOverlap = false
348
+ for (existing in visibleItems) {
349
+ if (itemsOverlap(ncItem, existing, worldSize)) {
350
+ hasOverlap = true
351
+ break
352
+ }
353
+ }
354
+
355
+ if (hasOverlap) {
356
+ ncMarker.isVisible = false
357
+ hiddenNonClusteredIds.add(id)
358
+ }
359
+ }
360
+ }
361
+
362
+ private fun itemsOverlap(a: RenderItem, b: RenderItem, worldSize: Double): Boolean {
363
+ val dx = mercatorX(a.longitude, worldSize) - mercatorX(b.longitude, worldSize)
364
+ val dy = mercatorY(a.latitude, worldSize) - mercatorY(b.latitude, worldSize)
365
+ val minSepX = (a.iconWidth + b.iconWidth) / 2.0
366
+ val minSepY = (a.iconHeight + b.iconHeight) / 2.0
367
+ return abs(dx) < minSepX && abs(dy) < minSepY
368
+ }
369
+
370
+ private fun mercatorX(longitude: Double, worldSize: Double): Double {
371
+ return (longitude + 180.0) / 360.0 * worldSize
372
+ }
373
+
374
+ private fun mercatorY(latitude: Double, worldSize: Double): Double {
375
+ val latRad = latitude * Math.PI / 180.0
376
+ return (1.0 - ln(tan(latRad) + 1.0 / cos(latRad)) / Math.PI) / 2.0 * worldSize
377
+ }
378
+
379
+ // ============================================================================
380
+ // MARK: - Marker Pool Management
381
+ // ============================================================================
382
+
383
+ fun GoogleMapProvider.clearRenderedClusterMarkers() {
384
+ for (marker in renderedClusterMarkers) {
385
+ marker.remove()
386
+ }
387
+ renderedClusterMarkers.clear()
388
+
389
+ for ((id, marker) in renderedSingleMarkers) {
390
+ if (id == selectedMarkerId) continue
391
+ marker.remove()
392
+ }
393
+ if (selectedMarkerId != null) {
394
+ val selectedMarker = renderedSingleMarkers[selectedMarkerId]
395
+ renderedSingleMarkers.clear()
396
+ selectedMarker?.let { renderedSingleMarkers[selectedMarkerId!!] = it }
397
+ } else {
398
+ renderedSingleMarkers.clear()
399
+ }
400
+ }
401
+
402
+ fun GoogleMapProvider.renderAllMarkersIndividually() {
403
+ val map = googleMap ?: return
404
+ // H-3: Use markerData (single source of truth)
405
+ // When global clustering is disabled, render ALL markers individually —
406
+ // don't filter by per-marker clusteringEnabled, because updateMarker may
407
+ // have already set it to false before this debounced call fires.
408
+ for ((_, data) in markerData) {
409
+ if (renderedSingleMarkers.containsKey(data.id)) continue
410
+ if (nonClusteredMarkers.containsKey(data.id)) continue
411
+ val opts = buildMarkerOptions(data)
412
+ val marker = map.addMarker(opts)
413
+ if (marker != null) {
414
+ marker.tag = MarkerTagData.Regular(data.id)
415
+ renderedSingleMarkers[data.id] = marker
416
+ }
417
+ }
418
+ }
419
+
420
+ // ============================================================================
421
+ // MARK: - Clustering Control
422
+ // ============================================================================
423
+
424
+ internal fun GoogleMapProvider.setClusteringEnabledInternal(enabled: Boolean) {
425
+ val existingConfig = clusterConfig
426
+ clusterConfig = if (existingConfig != null) {
427
+ ClusterConfig(
428
+ enabled = enabled,
429
+ strategy = existingConfig.strategy,
430
+ radius = existingConfig.radius,
431
+ minimumClusterSize = existingConfig.minimumClusterSize,
432
+ maxZoom = existingConfig.maxZoom,
433
+ backgroundColor = existingConfig.backgroundColor,
434
+ textColor = existingConfig.textColor,
435
+ borderWidth = existingConfig.borderWidth,
436
+ borderColor = existingConfig.borderColor,
437
+ animatesClusters = existingConfig.animatesClusters,
438
+ animateOnReappear = existingConfig.animateOnReappear,
439
+ animationDuration = existingConfig.animationDuration,
440
+ realtimeClustering = existingConfig.realtimeClustering,
441
+ renderBuffer = existingConfig.renderBuffer,
442
+ throttleInterval = existingConfig.throttleInterval
443
+ )
444
+ } else {
445
+ ClusterConfig(
446
+ enabled = enabled,
447
+ strategy = null, radius = null, minimumClusterSize = null,
448
+ maxZoom = null, backgroundColor = null, textColor = null,
449
+ borderWidth = null, borderColor = null, animatesClusters = null,
450
+ animateOnReappear = null, animationDuration = null,
451
+ realtimeClustering = null, renderBuffer = null, throttleInterval = null
452
+ )
453
+ }
454
+ performClustering()
455
+ }
456
+
457
+ internal fun GoogleMapProvider.refreshClustersInternal() {
458
+ performClustering()
459
+ }
460
+
461
+ // M-1: buildMarkerOptions and applyMarkerProperties are now shared functions
462
+ // defined in GoogleMapProvider+Markers.kt — no duplicates here.
463
+
464
+ // ============================================================================
465
+ // MARK: - Viewport Polygon Helpers (rotation-aware visibility)
466
+ // ============================================================================
467
+
468
+ /**
469
+ * Point-in-convex-quadrilateral test using cross products.
470
+ * The polygon must have vertices in order (CW or CCW).
471
+ * Returns true if (lat, lon) is inside the quadrilateral.
472
+ *
473
+ * For a convex polygon, a point is inside if it's on the same side
474
+ * of all edges. We check the sign of the cross product for each edge.
475
+ */
476
+ private fun pointInConvexQuad(lat: Double, lon: Double, quad: List<LatLng>): Boolean {
477
+ val n = quad.size
478
+ var positive = 0
479
+ var negative = 0
480
+
481
+ for (i in 0 until n) {
482
+ val x1 = quad[i].longitude
483
+ val y1 = quad[i].latitude
484
+ val x2 = quad[(i + 1) % n].longitude
485
+ val y2 = quad[(i + 1) % n].latitude
486
+
487
+ // Cross product of edge vector and point vector
488
+ val cross = (x2 - x1) * (lat - y1) - (y2 - y1) * (lon - x1)
489
+
490
+ if (cross > 0) positive++
491
+ else if (cross < 0) negative++
492
+
493
+ // If we have both positive and negative, point is outside
494
+ if (positive > 0 && negative > 0) return false
495
+ }
496
+ return true
497
+ }
498
+
499
+ /**
500
+ * Expands a convex polygon outward from its centroid by a fraction.
501
+ * Used to account for renderBuffer so markers near viewport edges
502
+ * aren't clipped prematurely.
503
+ */
504
+ private fun expandPolygon(polygon: List<LatLng>, fraction: Double): List<LatLng> {
505
+ if (fraction <= 0.0) return polygon
506
+
507
+ // Compute centroid
508
+ var cLat = 0.0
509
+ var cLon = 0.0
510
+ for (p in polygon) {
511
+ cLat += p.latitude
512
+ cLon += p.longitude
513
+ }
514
+ cLat /= polygon.size
515
+ cLon /= polygon.size
516
+
517
+ // Expand each vertex outward from centroid
518
+ return polygon.map { p ->
519
+ LatLng(
520
+ cLat + (p.latitude - cLat) * (1.0 + fraction),
521
+ cLon + (p.longitude - cLon) * (1.0 + fraction)
522
+ )
523
+ }
524
+ }