@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,541 @@
1
+ import Foundation
2
+ import GoogleMaps
3
+ import GoogleMapsUtils
4
+ import UIKit
5
+
6
+ // MARK: - Clustering
7
+
8
+ extension GoogleMapProvider {
9
+
10
+ /// A renderable item produced by the clustering pipeline.
11
+ /// Used by both `supercluster` and `hideOnOverlap` strategies.
12
+ struct RenderItem {
13
+ enum Kind {
14
+ case cluster(NitroClusterEngine.ClusterDataResult)
15
+ case single(markerId: String)
16
+ }
17
+ let coordinate: CLLocationCoordinate2D
18
+ let kind: Kind
19
+ let iconSize: CGSize
20
+ let priority: Int // Higher = more important (clusters > singles)
21
+ }
22
+
23
+ // MARK: - Icon Load Notification
24
+
25
+ /// Listen for async icon load completion to update markers
26
+ func setupIconLoadNotification() {
27
+ NotificationCenter.default.addObserver(
28
+ self,
29
+ selector: #selector(handleIconLoaded(_:)),
30
+ name: GoogleMapProvider.markerIconLoadedNotification,
31
+ object: nil
32
+ )
33
+ }
34
+
35
+ @objc func handleIconLoaded(_ notification: Notification) {
36
+ guard notification.userInfo?["icon"] is UIImage else {
37
+ return
38
+ }
39
+
40
+ // The cache has already been updated, so just refresh visible markers
41
+ DispatchQueue.main.async { [weak self] in
42
+ self?.refreshVisibleMarkerIcons()
43
+ }
44
+ }
45
+
46
+ /// Refresh icons for visible markers (used after async icon load)
47
+ func refreshVisibleMarkerIcons() {
48
+ // Only refresh image-style markers as they use async loading
49
+ for (id, gmsMarker) in renderedSingleMarkers {
50
+ if let markerData = clusterableMarkerData[id],
51
+ markerData.config.style == MarkerStyle.image {
52
+ // Re-fetch icon from cache (now should have the loaded image)
53
+ if let icon = MarkerIconFactory.createIcon(for: markerData) {
54
+ gmsMarker.icon = icon
55
+ }
56
+ }
57
+ }
58
+
59
+ for (id, gmsMarker) in nonClusteredMarkers {
60
+ if let markerData = clusterableMarkerData[id],
61
+ markerData.config.style == MarkerStyle.image {
62
+ if let icon = MarkerIconFactory.createIcon(for: markerData) {
63
+ gmsMarker.icon = icon
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ // MARK: - Settings
70
+
71
+ func updateSettings() {
72
+ // Props are already applied via didSet. Just trigger clustering
73
+ // to reconcile any pending state.
74
+ performClustering()
75
+ }
76
+
77
+ // MARK: - Cluster Configuration
78
+
79
+ func updateClusterConfig() {
80
+ clusteringManager.clusterConfig = clusterConfig
81
+ performClustering()
82
+ }
83
+
84
+ // MARK: - Clustering Pipeline
85
+
86
+ /// Debounced clustering — fires after 100ms of silence (used on idle / config changes)
87
+ func performClustering() {
88
+ // FIX #4: Use the single debounce in ClusteringManager
89
+ // instead of a second timer here.
90
+ clusteringManager.debounce { [weak self] in
91
+ self?.performClusteringImmediate()
92
+ }
93
+ }
94
+
95
+ /// Throttled clustering — fires at most once per interval during continuous gestures.
96
+ /// Unlike debounce, throttle fires immediately then blocks subsequent calls for the interval.
97
+ func performClusteringThrottled() {
98
+ // If throttle timer is active, skip — we already have a pending call
99
+ guard throttleTimer == nil else { return }
100
+
101
+ // Fire immediately
102
+ performClusteringImmediate()
103
+
104
+ // Read interval from config (ms → seconds), default 150ms
105
+ let intervalMs = clusterConfig?.throttleInterval ?? 150
106
+ let intervalSec = intervalMs / 1000.0
107
+
108
+ // Block subsequent calls for the interval
109
+ throttleTimer = Timer.scheduledTimer(withTimeInterval: intervalSec, repeats: false) { [weak self] _ in
110
+ self?.throttleTimer = nil
111
+ }
112
+ }
113
+
114
+ // swiftlint:disable:next function_body_length
115
+ func performClusteringImmediate() {
116
+ guard gmsMapView.frame.size.width > 0 && gmsMapView.frame.size.height > 0
117
+ else {
118
+ return
119
+ }
120
+
121
+ let enabled = clusterConfig?.enabled ?? true
122
+ guard enabled else {
123
+ clearRenderedMarkersToPool()
124
+ renderAllMarkersIndividually()
125
+ return
126
+ }
127
+
128
+ // --- Phase 1: Spatial grouping (C++ Supercluster engine) ---
129
+ let visibleRegion = gmsMapView.projection.visibleRegion()
130
+ let zoom = gmsMapView.camera.zoom
131
+ let mapSize = gmsMapView.frame.size
132
+
133
+ let bounds = GMSCoordinateBounds(region: visibleRegion)
134
+
135
+ // Expand bounds by renderBuffer (fraction of viewport size)
136
+ let padding = clusterConfig?.renderBuffer ?? 0
137
+ let latSpan = bounds.northEast.latitude - bounds.southWest.latitude
138
+ let lonSpan = bounds.northEast.longitude - bounds.southWest.longitude
139
+ let queryMinLat = bounds.southWest.latitude - latSpan * padding
140
+ let queryMaxLat = bounds.northEast.latitude + latSpan * padding
141
+ let queryMinLon = bounds.southWest.longitude - lonSpan * padding
142
+ let queryMaxLon = bounds.northEast.longitude + lonSpan * padding
143
+
144
+ let result = clusteringManager.cluster(
145
+ minLat: queryMinLat,
146
+ maxLat: queryMaxLat,
147
+ minLon: queryMinLon,
148
+ maxLon: queryMaxLon,
149
+ zoom: Double(zoom),
150
+ mapSize: mapSize
151
+ )
152
+
153
+ let minClusterSize = Int(clusterConfig?.minimumClusterSize ?? 2)
154
+ let strategy = clusterConfig?.strategy ?? .supercluster
155
+
156
+ // Build the actual visible polygon from the 4 corners of the rotated viewport.
157
+ // When bearing ≠ 0, the axis-aligned GMSCoordinateBounds is larger than the
158
+ // actual visible area. Filter results against this polygon to exclude markers
159
+ // in the "corner excess" of the bounding box.
160
+ let bearing = gmsMapView.camera.bearing
161
+ let isRotated = bearing.truncatingRemainder(dividingBy: 360.0) != 0.0
162
+ let visiblePolygon: [CLLocationCoordinate2D]? = isRotated ? expandPolygon([
163
+ visibleRegion.nearLeft,
164
+ visibleRegion.nearRight,
165
+ visibleRegion.farRight,
166
+ visibleRegion.farLeft
167
+ ], fraction: padding) : nil
168
+
169
+ // --- Phase 2: Build renderable items from engine output ---
170
+ var renderItems: [RenderItem] = []
171
+ renderItems.reserveCapacity(result.clusters.count + result.singleMarkers.count)
172
+
173
+ // Process clusters
174
+ for cluster in result.clusters {
175
+ // When rotated, filter out clusters outside the actual visible polygon
176
+ if let poly = visiblePolygon,
177
+ !pointInConvexQuad(lat: cluster.coordinate.latitude, lon: cluster.coordinate.longitude, quad: poly) {
178
+ continue
179
+ }
180
+
181
+ let containsSelected = selectedMarkerId != nil && cluster.markerIds.contains(selectedMarkerId!)
182
+ let adjustedCount = containsSelected ? cluster.count - 1 : cluster.count
183
+ guard adjustedCount >= minClusterSize else { continue }
184
+
185
+ let adjustedCluster = containsSelected
186
+ ? NitroClusterEngine.ClusterDataResult(
187
+ coordinate: cluster.coordinate,
188
+ markerIds: cluster.markerIds.filter { $0 != selectedMarkerId },
189
+ count: adjustedCount,
190
+ iconSize: cluster.iconSize
191
+ )
192
+ : cluster
193
+
194
+ let icon = clusteringManager.clusterIcon(forCount: adjustedCount)
195
+ let size = icon?.size ?? CGSize(width: 44, height: 44)
196
+
197
+ renderItems.append(RenderItem(
198
+ coordinate: cluster.coordinate,
199
+ kind: .cluster(adjustedCluster),
200
+ iconSize: size,
201
+ priority: adjustedCount + 10000
202
+ ))
203
+ }
204
+
205
+ // Process single markers
206
+ for single in result.singleMarkers {
207
+ // When rotated, filter out singles outside the actual visible polygon
208
+ if let poly = visiblePolygon,
209
+ !pointInConvexQuad(lat: single.latitude, lon: single.longitude, quad: poly) {
210
+ continue
211
+ }
212
+
213
+ let id = single.markerId
214
+ if id == selectedMarkerId { continue }
215
+ if nonClusteredMarkers[id] != nil { continue }
216
+ guard let data = clusterableMarkerData[id] else { continue }
217
+
218
+ let icon = MarkerIconFactory.createIcon(for: data)
219
+ let size = icon?.size ?? CGSize(width: 27, height: 43)
220
+
221
+ renderItems.append(RenderItem(
222
+ coordinate: CLLocationCoordinate2D(
223
+ latitude: single.latitude,
224
+ longitude: single.longitude
225
+ ),
226
+ kind: .single(markerId: id),
227
+ iconSize: size,
228
+ priority: 0
229
+ ))
230
+ }
231
+
232
+ // --- Phase 2b: Apply strategy ---
233
+ var visibleItems = renderItems
234
+
235
+ if strategy == .hideonoverlap {
236
+ visibleItems = resolveOverlaps(
237
+ items: renderItems,
238
+ zoom: Double(zoom),
239
+ mapWidth: mapSize.width
240
+ )
241
+ }
242
+ // For .supercluster strategy: render all items as-is (overlaps tolerated)
243
+
244
+ // --- Phase 3: Render — add new markers BEFORE removing old ones ---
245
+ // This prevents the visual flash that occurs when markers are destroyed
246
+ // then recreated. By adding first, the map is never empty.
247
+ let isFirstRender = renderedClusterMarkers.isEmpty && renderedSingleMarkers.isEmpty
248
+
249
+ // Stash old markers for removal after new ones are on the map
250
+ let oldClusterMarkers = renderedClusterMarkers
251
+ let oldSingleMarkers = renderedSingleMarkers
252
+ renderedClusterMarkers = []
253
+ renderedSingleMarkers = [:]
254
+
255
+ // Preserve selected marker reference — it was excluded from the clustering
256
+ // engine, so it won't appear in the clustering output. Keep its GMSMarker
257
+ // in the dictionary so deselectCurrentMarker() can find it later.
258
+ if let selectedId = selectedMarkerId, let selectedMarker = oldSingleMarkers[selectedId] {
259
+ renderedSingleMarkers[selectedId] = selectedMarker
260
+ }
261
+
262
+ // Restore previously hidden non-clustered markers
263
+ for id in hiddenNonClusteredIds {
264
+ nonClusteredMarkers[id]?.map = gmsMapView
265
+ }
266
+ hiddenNonClusteredIds.removeAll()
267
+
268
+ // Render new items (add to map)
269
+ for (renderIndex, item) in visibleItems.enumerated() {
270
+ switch item.kind {
271
+ case .cluster(let clusterData):
272
+ let marker = GMSMarker()
273
+ marker.position = clusterData.coordinate
274
+ marker.groundAnchor = CGPoint(x: 0.5, y: 0.5)
275
+
276
+ if let icon = clusteringManager.clusterIcon(forCount: clusterData.count) {
277
+ marker.icon = icon
278
+ }
279
+
280
+ marker.userData = ClusterUserData(
281
+ markerIds: clusterData.markerIds,
282
+ count: clusterData.count
283
+ )
284
+
285
+ // Accessibility: announce cluster count to screen readers
286
+ marker.accessibilityLabel = "Cluster of \(clusterData.count) markers"
287
+
288
+ // Larger clusters render on top for consistent visual ordering
289
+ marker.zIndex = Int32(clusterData.count + 1000)
290
+
291
+ // Animate cluster icons when animatesClusters is enabled.
292
+ // Only animate when cluster structure actually changes:
293
+ // - First render (markers just appeared)
294
+ // - Zoom level changed (clusters restructure)
295
+ // - Markers added/removed (clusteringDirty)
296
+ // Skip on pure pan at same zoom (same clusters, just repositioned).
297
+ let zoomChanged = floor(zoom) != floor(lastClusteredZoom)
298
+ let structureChanged = isFirstRender || zoomChanged || clusteringDirty
299
+ let animateOnReappear = clusterConfig?.animateOnReappear ?? true
300
+ let shouldAnimate = (clusterConfig?.animatesClusters ?? true)
301
+ && (isFirstRender || (animateOnReappear && structureChanged))
302
+ if shouldAnimate {
303
+ applyClusterAnimation(marker)
304
+ }
305
+
306
+ marker.map = gmsMapView
307
+ renderedClusterMarkers.append(marker)
308
+
309
+ case .single(let markerId):
310
+ guard let data = clusterableMarkerData[markerId] else { continue }
311
+ // Reuse the existing GMSMarker if this markerId was already rendered.
312
+ // Skip full property update — the marker already has correct props
313
+ // from updateMarkerInPlace(). Only zIndex (render order) changes
314
+ // between clustering cycles. Re-setting icon was causing flicker
315
+ // on every camera idle (e.g. during user location tracking).
316
+ if let existing = oldSingleMarkers[markerId] {
317
+ existing.zIndex = Int32(renderIndex)
318
+ renderedSingleMarkers[markerId] = existing
319
+ } else {
320
+ let marker = GMSMarker()
321
+ configureGMSMarker(marker, from: data)
322
+ // Suppress appear animation in clustering pipeline when animateOnReappear is false
323
+ if !data.animateOnReappear {
324
+ marker.appearAnimation = .none
325
+ }
326
+ marker.zIndex = Int32(renderIndex)
327
+ marker.map = gmsMapView
328
+ renderedSingleMarkers[markerId] = marker
329
+ }
330
+ }
331
+ }
332
+
333
+ // NOW remove old markers (after new ones are visible)
334
+ for marker in oldClusterMarkers {
335
+ marker.map = nil
336
+ }
337
+ for (id, marker) in oldSingleMarkers {
338
+ // Skip markers that were reused in the new render
339
+ if renderedSingleMarkers[id] != nil { continue }
340
+ // Preserve the selected marker — excluded from clustering
341
+ if id == selectedMarkerId { continue }
342
+ marker.map = nil
343
+ }
344
+
345
+ // Handle non-clustered markers with hideOnOverlap
346
+ if strategy == .hideonoverlap {
347
+ hideOverlappingNonClusteredMarkers(
348
+ visibleItems: visibleItems,
349
+ zoom: Double(zoom),
350
+ mapWidth: mapSize.width
351
+ )
352
+ }
353
+
354
+ // Track state for next cycle's animation decisions
355
+ lastClusteredZoom = zoom
356
+ clusteringDirty = false
357
+
358
+ }
359
+
360
+ // MARK: - Overlap Resolution (hideOnOverlap strategy)
361
+
362
+ /// Removes lower-priority items that visually overlap higher-priority items.
363
+ /// Uses Mercator projection math — no GMSProjection dependency.
364
+ func resolveOverlaps(
365
+ items: [RenderItem],
366
+ zoom: Double,
367
+ mapWidth: CGFloat
368
+ ) -> [RenderItem] {
369
+ let sorted = items.sorted { $0.priority > $1.priority }
370
+ var visible: [RenderItem] = []
371
+ visible.reserveCapacity(sorted.count)
372
+
373
+ let worldSize = 256.0 * pow(2.0, zoom)
374
+
375
+ for item in sorted {
376
+ var hasOverlap = false
377
+ for existing in visible {
378
+ if itemsOverlap(item, existing, worldSize: worldSize) {
379
+ hasOverlap = true
380
+ break
381
+ }
382
+ }
383
+ if !hasOverlap {
384
+ visible.append(item)
385
+ }
386
+ }
387
+
388
+ return visible
389
+ }
390
+
391
+ /// Hides non-clustered markers that overlap with already-rendered items.
392
+ func hideOverlappingNonClusteredMarkers(
393
+ visibleItems: [RenderItem],
394
+ zoom: Double,
395
+ mapWidth: CGFloat
396
+ ) {
397
+ let worldSize = 256.0 * pow(2.0, zoom)
398
+
399
+ for (id, ncMarker) in nonClusteredMarkers {
400
+ let data = clusterableMarkerData[id]
401
+ let icon = data.flatMap { MarkerIconFactory.createIcon(for: $0) }
402
+ let size = icon?.size ?? CGSize(width: Self.defaultMarkerSize, height: Self.defaultMarkerSize)
403
+
404
+ let ncItem = RenderItem(
405
+ coordinate: ncMarker.position,
406
+ kind: .single(markerId: id),
407
+ iconSize: size,
408
+ priority: 0
409
+ )
410
+
411
+ var hasOverlap = false
412
+ for existing in visibleItems {
413
+ if itemsOverlap(ncItem, existing, worldSize: worldSize) {
414
+ hasOverlap = true
415
+ break
416
+ }
417
+ }
418
+
419
+ if hasOverlap {
420
+ ncMarker.map = nil
421
+ hiddenNonClusteredIds.insert(id)
422
+ }
423
+ }
424
+ }
425
+
426
+ /// Checks if two render items overlap in screen space using Mercator projection.
427
+ func itemsOverlap(_ a: RenderItem, _ b: RenderItem, worldSize: Double) -> Bool {
428
+ let dx: Double = mercatorX(a.coordinate.longitude, worldSize: worldSize)
429
+ - mercatorX(b.coordinate.longitude, worldSize: worldSize)
430
+ let dy: Double = mercatorY(a.coordinate.latitude, worldSize: worldSize)
431
+ - mercatorY(b.coordinate.latitude, worldSize: worldSize)
432
+ let minSepX: Double = Double(a.iconSize.width + b.iconSize.width) / 2.0
433
+ let minSepY: Double = Double(a.iconSize.height + b.iconSize.height) / 2.0
434
+ return Swift.abs(dx) < minSepX && Swift.abs(dy) < minSepY
435
+ }
436
+
437
+ // MARK: - Mercator Math Helpers
438
+
439
+ func mercatorX(_ longitude: Double, worldSize: Double) -> Double {
440
+ return (longitude + 180.0) / 360.0 * worldSize
441
+ }
442
+
443
+ func mercatorY(_ latitude: Double, worldSize: Double) -> Double {
444
+ let latRad = latitude * .pi / 180.0
445
+ return (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * worldSize
446
+ }
447
+
448
+ // MARK: - Marker Pool Management
449
+
450
+ func clearRenderedMarkersToPool() {
451
+ for marker in renderedClusterMarkers {
452
+ marker.map = nil
453
+ }
454
+ renderedClusterMarkers.removeAll()
455
+
456
+ for (id, marker) in renderedSingleMarkers {
457
+ // Preserve the selected marker — it's excluded from clustering
458
+ // and must keep its GMSMarker object alive for the info window.
459
+ if id == selectedMarkerId { continue }
460
+ marker.map = nil
461
+ }
462
+ // Keep only the selected marker in the dictionary
463
+ if let selectedId = selectedMarkerId, let selectedMarker = renderedSingleMarkers[selectedId] {
464
+ renderedSingleMarkers.removeAll()
465
+ renderedSingleMarkers[selectedId] = selectedMarker
466
+ } else {
467
+ renderedSingleMarkers.removeAll()
468
+ }
469
+ }
470
+
471
+ func renderAllMarkersIndividually() {
472
+ for (_, markerData) in clusterableMarkerData {
473
+ if renderedSingleMarkers[markerData.id] == nil {
474
+ let marker = GMSMarker()
475
+ configureGMSMarker(marker, from: markerData)
476
+ marker.map = gmsMapView
477
+ renderedSingleMarkers[markerData.id] = marker
478
+ }
479
+ }
480
+ }
481
+
482
+ // MARK: - Clustering Control
483
+
484
+ func setClusteringEnabled(_ enabled: Bool) {
485
+ self.clusterConfig = ClusterConfig.withEnabled(enabled, existing: clusterConfig)
486
+ performClustering()
487
+ }
488
+
489
+ func refreshClusters() {
490
+ performClustering()
491
+ }
492
+
493
+ // MARK: - Viewport Polygon Helpers (rotation-aware visibility)
494
+
495
+ /// Point-in-convex-quadrilateral test using cross products.
496
+ /// Returns true if (lat, lon) is inside the quadrilateral.
497
+ /// For a convex polygon, a point is inside if it's on the same side of all edges.
498
+ private func pointInConvexQuad(
499
+ lat: Double, lon: Double,
500
+ quad: [CLLocationCoordinate2D]
501
+ ) -> Bool {
502
+ let n = quad.count
503
+ var positive = 0
504
+ var negative = 0
505
+
506
+ for i in 0..<n {
507
+ let x1 = quad[i].longitude
508
+ let y1 = quad[i].latitude
509
+ let x2 = quad[(i + 1) % n].longitude
510
+ let y2 = quad[(i + 1) % n].latitude
511
+
512
+ let cross = (x2 - x1) * (lat - y1) - (y2 - y1) * (lon - x1)
513
+
514
+ if cross > 0 { positive += 1 }
515
+ else if cross < 0 { negative += 1 }
516
+
517
+ if positive > 0 && negative > 0 { return false }
518
+ }
519
+ return true
520
+ }
521
+
522
+ /// Expands a convex polygon outward from its centroid by a fraction.
523
+ /// Used to account for renderBuffer so markers near viewport edges
524
+ /// aren't clipped prematurely.
525
+ private func expandPolygon(
526
+ _ polygon: [CLLocationCoordinate2D],
527
+ fraction: Double
528
+ ) -> [CLLocationCoordinate2D] {
529
+ guard fraction > 0 else { return polygon }
530
+
531
+ let cLat = polygon.reduce(0.0) { $0 + $1.latitude } / Double(polygon.count)
532
+ let cLon = polygon.reduce(0.0) { $0 + $1.longitude } / Double(polygon.count)
533
+
534
+ return polygon.map { p in
535
+ CLLocationCoordinate2D(
536
+ latitude: cLat + (p.latitude - cLat) * (1.0 + fraction),
537
+ longitude: cLon + (p.longitude - cLon) * (1.0 + fraction)
538
+ )
539
+ }
540
+ }
541
+ }