@maydon_tech/react-native-nitro-maps 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) 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/gradle.properties +4 -4
  6. package/android/src/main/cpp/ClusterEngineJNI.cpp +198 -0
  7. package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMap.kt +397 -0
  8. package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMapConfig.kt +53 -0
  9. package/android/src/main/{java → kotlin}/com/margelo/nitro/nitromap/NitroMapPackage.kt +4 -4
  10. package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMapView.kt +73 -0
  11. package/android/src/main/kotlin/com/margelo/nitro/nitromap/UserLocationManager.kt +295 -0
  12. package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/ClusterIconRenderer.kt +111 -0
  13. package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/ClusteringManager.kt +104 -0
  14. package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/NitroClusterEngine.kt +166 -0
  15. package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/MarkerIconFactory.kt +303 -0
  16. package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/MarkerSelectionHandler.kt +72 -0
  17. package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/PriceMarkerRenderer.kt +159 -0
  18. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/MapProviderFactory.kt +24 -0
  19. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/MapProviderInterface.kt +128 -0
  20. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapDelegate.kt +317 -0
  21. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Clustering.kt +524 -0
  22. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Markers.kt +358 -0
  23. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Overlays.kt +272 -0
  24. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+UserLocation.kt +296 -0
  25. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider.kt +815 -0
  26. package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/MarkerTagData.kt +19 -0
  27. package/ios/Clustering/ClusterIconRenderer.swift +3 -3
  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 +135 -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 +75 -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++/JFunc_void_UserLocationChangeEvent.hpp +79 -0
  125. package/nitrogen/generated/android/c++/JFunc_void_UserTrackingMode.hpp +77 -0
  126. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  127. package/nitrogen/generated/android/c++/JHybridNitroMapSpec.cpp +328 -21
  128. package/nitrogen/generated/android/c++/JHybridNitroMapSpec.hpp +53 -2
  129. package/nitrogen/generated/android/c++/JMarkerAnimation.hpp +3 -6
  130. package/nitrogen/generated/android/c++/JMarkerData.hpp +15 -3
  131. package/nitrogen/generated/android/c++/JPolygonData.hpp +149 -0
  132. package/nitrogen/generated/android/c++/JPolylineData.hpp +113 -0
  133. package/nitrogen/generated/android/c++/JUserLocationChangeEvent.hpp +70 -0
  134. package/nitrogen/generated/android/c++/JUserTrackingMode.hpp +62 -0
  135. package/nitrogen/generated/android/c++/views/JHybridNitroMapStateUpdater.cpp +72 -4
  136. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/CircleData.kt +62 -0
  137. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/ClusterConfig.kt +4 -4
  138. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_UserLocationChangeEvent.kt +80 -0
  139. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_UserTrackingMode.kt +80 -0
  140. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_std__string.kt +80 -0
  141. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/HybridNitroMapSpec.kt +228 -2
  142. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/MarkerAnimation.kt +1 -2
  143. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/MarkerData.kt +12 -3
  144. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/PolygonData.kt +62 -0
  145. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/PolylineData.kt +62 -0
  146. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/UserLocationChangeEvent.kt +47 -0
  147. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/{ClusterAnimationStyle.kt → UserTrackingMode.kt} +6 -8
  148. package/nitrogen/generated/android/nitromapOnLoad.cpp +6 -0
  149. package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Bridge.cpp +24 -0
  150. package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Bridge.hpp +175 -17
  151. package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Umbrella.hpp +15 -3
  152. package/nitrogen/generated/ios/c++/HybridNitroMapSpecSwift.hpp +249 -16
  153. package/nitrogen/generated/ios/c++/views/HybridNitroMapComponent.mm +90 -5
  154. package/nitrogen/generated/ios/swift/CircleData.swift +143 -0
  155. package/nitrogen/generated/ios/swift/ClusterConfig.swift +22 -15
  156. package/nitrogen/generated/ios/swift/Func_void_UserLocationChangeEvent.swift +47 -0
  157. package/nitrogen/generated/ios/swift/Func_void_UserTrackingMode.swift +47 -0
  158. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
  159. package/nitrogen/generated/ios/swift/HybridNitroMapSpec.swift +35 -1
  160. package/nitrogen/generated/ios/swift/HybridNitroMapSpec_cxx.swift +582 -8
  161. package/nitrogen/generated/ios/swift/MarkerAnimation.swift +4 -8
  162. package/nitrogen/generated/ios/swift/MarkerData.swift +54 -2
  163. package/nitrogen/generated/ios/swift/PolygonData.swift +179 -0
  164. package/nitrogen/generated/ios/swift/PolylineData.swift +155 -0
  165. package/nitrogen/generated/ios/swift/UserLocationChangeEvent.swift +69 -0
  166. package/nitrogen/generated/ios/swift/UserTrackingMode.swift +44 -0
  167. package/nitrogen/generated/shared/c++/CircleData.hpp +113 -0
  168. package/nitrogen/generated/shared/c++/ClusterConfig.hpp +5 -8
  169. package/nitrogen/generated/shared/c++/HybridNitroMapSpec.cpp +53 -2
  170. package/nitrogen/generated/shared/c++/HybridNitroMapSpec.hpp +75 -6
  171. package/nitrogen/generated/shared/c++/MarkerAnimation.hpp +4 -8
  172. package/nitrogen/generated/shared/c++/MarkerData.hpp +14 -2
  173. package/nitrogen/generated/shared/c++/PolygonData.hpp +114 -0
  174. package/nitrogen/generated/shared/c++/PolylineData.hpp +114 -0
  175. package/nitrogen/generated/shared/c++/UserLocationChangeEvent.hpp +88 -0
  176. package/nitrogen/generated/shared/c++/UserTrackingMode.hpp +80 -0
  177. package/nitrogen/generated/shared/c++/views/HybridNitroMapComponent.cpp +216 -12
  178. package/nitrogen/generated/shared/c++/views/HybridNitroMapComponent.hpp +23 -1
  179. package/nitrogen/generated/shared/json/NitroMapConfig.json +18 -1
  180. package/package.json +36 -5
  181. package/src/components/ImageMarker.tsx +58 -42
  182. package/src/components/Marker.tsx +161 -0
  183. package/src/components/NitroCircle.tsx +183 -0
  184. package/src/components/NitroMap.tsx +328 -78
  185. package/src/components/NitroPolygon.tsx +229 -0
  186. package/src/components/NitroPolyline.tsx +208 -0
  187. package/src/components/PriceMarker.tsx +23 -48
  188. package/src/context/NitroMapContext.tsx +4 -0
  189. package/src/hooks/useNitroCircle.ts +25 -0
  190. package/src/hooks/useNitroMarker.ts +49 -10
  191. package/src/hooks/useNitroOverlay.ts +68 -0
  192. package/src/hooks/useNitroPolygon.ts +25 -0
  193. package/src/hooks/useNitroPolyline.ts +25 -0
  194. package/src/index.tsx +23 -2
  195. package/src/specs/NitroMap.nitro.ts +294 -5
  196. package/src/types/map.ts +36 -4
  197. package/src/types/marker.ts +24 -44
  198. package/src/types/overlay.ts +77 -0
  199. package/src/types/theme.ts +101 -0
  200. package/src/utils/colors.ts +48 -16
  201. package/src/utils/validation.ts +69 -0
  202. package/android/src/main/java/com/margelo/nitro/nitromap/ClusterIconGenerator.kt +0 -108
  203. package/android/src/main/java/com/margelo/nitro/nitromap/ColorUtils.kt +0 -63
  204. package/android/src/main/java/com/margelo/nitro/nitromap/HybridNitroMap.kt +0 -408
  205. package/android/src/main/java/com/margelo/nitro/nitromap/HybridNitroMapConfig.kt +0 -68
  206. package/android/src/main/java/com/margelo/nitro/nitromap/MarkerIconCache.kt +0 -176
  207. package/android/src/main/java/com/margelo/nitro/nitromap/MarkerIconFactory.kt +0 -252
  208. package/android/src/main/java/com/margelo/nitro/nitromap/clustering/NitroClusterEngine.kt +0 -252
  209. package/android/src/main/java/com/margelo/nitro/nitromap/clustering/QuadTree.kt +0 -195
  210. package/android/src/main/java/com/margelo/nitro/nitromap/providers/GoogleMapProvider.kt +0 -912
  211. package/android/src/main/java/com/margelo/nitro/nitromap/providers/MapProviderInterface.kt +0 -70
  212. package/cpp/QuadTree.hpp +0 -246
  213. package/ios/NitroMapConfig/HybridNitroMapConfig.swift +0 -33
  214. package/ios/Providers/GoogleMapProvider+Camera.swift +0 -164
  215. package/ios/Providers/GoogleMapProvider.swift +0 -924
  216. package/nitrogen/generated/android/c++/JClusterAnimationStyle.hpp +0 -68
  217. package/nitrogen/generated/ios/swift/ClusterAnimationStyle.swift +0 -52
  218. package/nitrogen/generated/shared/c++/ClusterAnimationStyle.hpp +0 -88
@@ -1,924 +0,0 @@
1
- import Foundation
2
- import GoogleMaps
3
- import GoogleMapsUtils
4
- import UIKit
5
-
6
- /// Google Maps implementation of MapProviderProtocol
7
- class GoogleMapProvider: MapProviderProtocol {
8
-
9
- // MARK: - Constants
10
-
11
- private static let defaultLatitude: Double = 41.2995
12
- private static let defaultLongitude: Double = 69.2401
13
-
14
- // MARK: - Properties
15
-
16
- let gmsMapView: GMSMapView
17
- var mapView: UIView { return gmsMapView }
18
-
19
- private var mapDelegate: GoogleMapDelegate?
20
-
21
- // MARK: - Injected Dependencies (DIP)
22
-
23
- private let clusteringManager: ClusteringManagerProtocol
24
- private let selectionHandler: MarkerSelectionHandling
25
-
26
- // Rendered markers
27
- private(set) var renderedClusterMarkers: [GMSMarker] = []
28
- private var renderedSingleMarkers: [String: GMSMarker] = [:]
29
- private var nonClusteredMarkers: [String: GMSMarker] = [:]
30
- private var hiddenNonClusteredIds: Set<String> = [] // Non-clustered markers hidden due to overlap
31
- private var clusterableMarkerData: [String: MarkerData] = [:]
32
-
33
- private var selectedMarkerId: String?
34
-
35
- private static let defaultMarkerSize: CGFloat = 44
36
-
37
- // MARK: - Clustering Render Item
38
-
39
- /// A renderable item produced by the clustering pipeline.
40
- /// Used by both `supercluster` and `hideOnOverlap` strategies.
41
- private struct RenderItem {
42
- enum Kind {
43
- case cluster(NitroClusterEngine.ClusterDataResult)
44
- case single(markerId: String)
45
- }
46
- let coordinate: CLLocationCoordinate2D
47
- let kind: Kind
48
- let iconSize: CGSize
49
- let priority: Int // Higher = more important (clusters > singles)
50
- }
51
-
52
- // MARK: - Protocol Properties
53
-
54
- var initialRegion: Region? = Region(latitude: defaultLatitude, longitude: defaultLongitude, latitudeDelta: 0.15, longitudeDelta: 0.15) {
55
- didSet {
56
- // Only move to initial region on first set, not on every re-render
57
- guard oldValue == nil else { return }
58
- updateCameraToInitialRegion()
59
- }
60
- }
61
-
62
- var showsUserLocation: Bool? {
63
- didSet { gmsMapView.isMyLocationEnabled = showsUserLocation ?? false }
64
- }
65
-
66
- var zoomEnabled: Bool? {
67
- didSet { gmsMapView.settings.zoomGestures = zoomEnabled ?? true }
68
- }
69
-
70
- var scrollEnabled: Bool? {
71
- didSet { gmsMapView.settings.scrollGestures = scrollEnabled ?? true }
72
- }
73
-
74
- var rotateEnabled: Bool? {
75
- didSet { gmsMapView.settings.rotateGestures = rotateEnabled ?? true }
76
- }
77
-
78
- var pitchEnabled: Bool? {
79
- didSet { gmsMapView.settings.tiltGestures = pitchEnabled ?? true }
80
- }
81
-
82
- var mapType: MapType? {
83
- didSet { gmsMapView.mapType = convertMapType(mapType) }
84
- }
85
-
86
- var showsMyLocationButton: Bool? {
87
- didSet {
88
- gmsMapView.settings.myLocationButton = showsMyLocationButton ?? false
89
- }
90
- }
91
-
92
- var clusterConfig: ClusterConfig? {
93
- didSet {
94
- #if DEBUG
95
- NSLog("[Clustering] didSet clusterConfig strategy=%@",
96
- clusterConfig?.strategy?.stringValue ?? "nil")
97
- #endif
98
- updateClusterConfig()
99
- }
100
- }
101
-
102
- var customMapStyle: [MapStyleElement]? {
103
- didSet { applyMapStyle() }
104
- }
105
-
106
- var darkMode: Bool? {
107
- didSet { applyDarkMode() }
108
- }
109
-
110
- // MARK: - Callbacks
111
-
112
- var onPress: ((MapPressEvent) -> Void)?
113
- var onLongPress: ((MapPressEvent) -> Void)?
114
- var onMapReady: (() -> Void)?
115
- var onRegionChange: ((RegionChangeEvent) -> Void)?
116
- var onRegionChangeComplete: ((RegionChangeEvent) -> Void)?
117
- var onMarkerPress: ((MarkerPressEvent) -> Void)?
118
- var onMarkerDragStart: ((MarkerDragEvent) -> Void)?
119
- var onMarkerDrag: ((MarkerDragEvent) -> Void)?
120
- var onMarkerDragEnd: ((MarkerDragEvent) -> Void)?
121
- var onClusterPress: ((ClusterPressEvent) -> Void)?
122
- var onError: ((MapError) -> Void)?
123
-
124
- // MARK: - Initialization
125
-
126
- /// Creates a GoogleMapProvider with injected dependencies.
127
- /// Uses default production implementations if none provided.
128
- ///
129
- /// - Parameters:
130
- /// - clusteringManager: Clustering logic handler (default: ClusteringManager)
131
- /// - selectionHandler: Marker selection handler (default: MarkerSelectionHandler.shared)
132
- init(
133
- clusteringManager: ClusteringManagerProtocol = ClusteringManager(),
134
- selectionHandler: MarkerSelectionHandling = MarkerSelectionHandler.shared
135
- ) {
136
- self.clusteringManager = clusteringManager
137
- self.selectionHandler = selectionHandler
138
-
139
- let camera = GMSCameraPosition.camera(
140
- withLatitude: Self.defaultLatitude,
141
- longitude: Self.defaultLongitude,
142
- zoom: 10
143
- )
144
-
145
- // Use GMSMapViewOptions (required for Google Maps SDK v10.0+)
146
- let options = GMSMapViewOptions()
147
- options.camera = camera
148
- options.frame = .zero
149
-
150
- let mapView = GMSMapView(options: options)
151
- mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
152
- self.gmsMapView = mapView
153
-
154
- setupIconLoadNotification()
155
- }
156
-
157
- deinit {
158
- NotificationCenter.default.removeObserver(self)
159
- }
160
-
161
- // MARK: - Lifecycle
162
-
163
- func setup() {
164
- mapDelegate = GoogleMapDelegate(provider: self)
165
- gmsMapView.delegate = mapDelegate
166
- }
167
-
168
- /// Listen for async icon load completion to update markers
169
- private func setupIconLoadNotification() {
170
- NotificationCenter.default.addObserver(
171
- self,
172
- selector: #selector(handleIconLoaded(_:)),
173
- name: Notification.Name("MarkerIconLoaded"),
174
- object: nil
175
- )
176
- }
177
-
178
- @objc private func handleIconLoaded(_ notification: Notification) {
179
- guard notification.userInfo?["icon"] is UIImage else {
180
- return
181
- }
182
-
183
- // The cache has already been updated, so just refresh visible markers
184
- DispatchQueue.main.async { [weak self] in
185
- self?.refreshVisibleMarkerIcons()
186
- }
187
- }
188
-
189
- /// Refresh icons for visible markers (used after async icon load)
190
- private func refreshVisibleMarkerIcons() {
191
- // Only refresh image-style markers as they use async loading
192
- for (id, gmsMarker) in renderedSingleMarkers {
193
- if let markerData = clusterableMarkerData[id],
194
- markerData.config.style == MarkerStyle.image {
195
- // Re-fetch icon from cache (now should have the loaded image)
196
- if let icon = MarkerIconFactory.createIcon(for: markerData) {
197
- gmsMarker.icon = icon
198
- }
199
- }
200
- }
201
-
202
- for (id, gmsMarker) in nonClusteredMarkers {
203
- if let markerData = clusterableMarkerData[id],
204
- markerData.config.style == MarkerStyle.image {
205
- if let icon = MarkerIconFactory.createIcon(for: markerData) {
206
- gmsMarker.icon = icon
207
- }
208
- }
209
- }
210
- }
211
-
212
- func updateSettings() {
213
- // Props are already applied via didSet. Just trigger clustering
214
- // to reconcile any pending state.
215
- performClustering()
216
- }
217
-
218
- // MARK: - Cluster Configuration
219
-
220
- private func updateClusterConfig() {
221
- #if DEBUG
222
- NSLog("[Clustering] updateClusterConfig called")
223
- #endif
224
- clusteringManager.clusterConfig = clusterConfig
225
- performClustering()
226
- }
227
-
228
- // MARK: - Clustering
229
-
230
- /// Debounced clustering — fires after 100ms of silence (used on idle / config changes)
231
- func performClustering() {
232
- // FIX #4: Use the single debounce in ClusteringManager
233
- // instead of a second timer here.
234
- clusteringManager.debounce { [weak self] in
235
- self?.performClusteringImmediate()
236
- }
237
- }
238
-
239
- /// Throttled clustering — fires at most once per interval during continuous gestures.
240
- /// Unlike debounce, throttle fires immediately then blocks subsequent calls for the interval.
241
- private var throttleTimer: Timer?
242
-
243
- func performClusteringThrottled() {
244
- // If throttle timer is active, skip — we already have a pending call
245
- guard throttleTimer == nil else { return }
246
-
247
- // Fire immediately
248
- performClusteringImmediate()
249
-
250
- // Read interval from config (ms → seconds), default 150ms
251
- let intervalMs = clusterConfig?.throttleInterval ?? 150
252
- let intervalSec = intervalMs / 1000.0
253
-
254
- // Block subsequent calls for the interval
255
- throttleTimer = Timer.scheduledTimer(withTimeInterval: intervalSec, repeats: false) { [weak self] _ in
256
- self?.throttleTimer = nil
257
- }
258
- }
259
-
260
- private func performClusteringImmediate() {
261
- guard gmsMapView.frame.size.width > 0 && gmsMapView.frame.size.height > 0
262
- else {
263
- return
264
- }
265
-
266
- let enabled = clusterConfig?.enabled ?? true
267
- guard enabled else {
268
- clearRenderedMarkersToPool()
269
- renderAllMarkersIndividually()
270
- return
271
- }
272
-
273
- // --- Phase 1: Spatial grouping (C++ Supercluster engine) ---
274
- let visibleRegion = gmsMapView.projection.visibleRegion()
275
- let zoom = gmsMapView.camera.zoom
276
- let mapSize = gmsMapView.frame.size
277
-
278
- let bounds = GMSCoordinateBounds(region: visibleRegion)
279
-
280
- // Expand bounds by renderBuffer (fraction of viewport size)
281
- let padding = clusterConfig?.renderBuffer ?? 0
282
- let latSpan = bounds.northEast.latitude - bounds.southWest.latitude
283
- let lonSpan = bounds.northEast.longitude - bounds.southWest.longitude
284
- let queryMinLat = bounds.southWest.latitude - latSpan * padding
285
- let queryMaxLat = bounds.northEast.latitude + latSpan * padding
286
- let queryMinLon = bounds.southWest.longitude - lonSpan * padding
287
- let queryMaxLon = bounds.northEast.longitude + lonSpan * padding
288
-
289
- #if DEBUG
290
- NSLog("[Clustering] zoom=%.6f SW=(%.6f,%.6f) NE=(%.6f,%.6f) padding=%.2f",
291
- Double(zoom),
292
- bounds.southWest.latitude, bounds.southWest.longitude,
293
- bounds.northEast.latitude, bounds.northEast.longitude,
294
- padding)
295
- #endif
296
-
297
- let result = clusteringManager.cluster(
298
- minLat: queryMinLat,
299
- maxLat: queryMaxLat,
300
- minLon: queryMinLon,
301
- maxLon: queryMaxLon,
302
- zoom: Double(zoom),
303
- mapSize: mapSize
304
- )
305
-
306
- let minClusterSize = Int(clusterConfig?.minimumClusterSize ?? 2)
307
- let strategy = clusterConfig?.strategy ?? .supercluster
308
-
309
- // --- Phase 2: Build renderable items from engine output ---
310
- var renderItems: [RenderItem] = []
311
- renderItems.reserveCapacity(result.clusters.count + result.singleMarkers.count)
312
-
313
- // Process clusters
314
- for cluster in result.clusters {
315
- let containsSelected = selectedMarkerId != nil && cluster.markerIds.contains(selectedMarkerId!)
316
- let adjustedCount = containsSelected ? cluster.count - 1 : cluster.count
317
- guard adjustedCount >= minClusterSize else { continue }
318
-
319
- let adjustedCluster = containsSelected
320
- ? NitroClusterEngine.ClusterDataResult(
321
- coordinate: cluster.coordinate,
322
- markerIds: cluster.markerIds.filter { $0 != selectedMarkerId },
323
- count: adjustedCount,
324
- iconSize: cluster.iconSize
325
- )
326
- : cluster
327
-
328
- let icon = clusteringManager.clusterIcon(forCount: adjustedCount)
329
- let size = icon?.size ?? CGSize(width: 44, height: 44)
330
-
331
- renderItems.append(RenderItem(
332
- coordinate: cluster.coordinate,
333
- kind: .cluster(adjustedCluster),
334
- iconSize: size,
335
- priority: adjustedCount + 10000
336
- ))
337
- }
338
-
339
- // Process single markers
340
- for single in result.singleMarkers {
341
- let id = single.markerId
342
- if id == selectedMarkerId { continue }
343
- if nonClusteredMarkers[id] != nil { continue }
344
- guard let data = clusterableMarkerData[id] else { continue }
345
-
346
- let icon = MarkerIconFactory.createIcon(for: data)
347
- let size = icon?.size ?? CGSize(width: 27, height: 43)
348
-
349
- renderItems.append(RenderItem(
350
- coordinate: CLLocationCoordinate2D(
351
- latitude: single.latitude,
352
- longitude: single.longitude
353
- ),
354
- kind: .single(markerId: id),
355
- iconSize: size,
356
- priority: 0
357
- ))
358
- }
359
-
360
- // --- Phase 2b: Apply strategy ---
361
- var visibleItems = renderItems
362
-
363
- #if DEBUG
364
- var clusterDetails: [String] = []
365
- for item in renderItems {
366
- if case .cluster(let c) = item.kind {
367
- clusterDetails.append(String(format: "%d@(%.4f,%.4f)", c.count, c.coordinate.latitude, c.coordinate.longitude))
368
- }
369
- }
370
- let singleCount = renderItems.count - clusterDetails.count
371
- let detailStr = clusterDetails.joined(separator: " ")
372
- NSLog("[Clustering] items=%d singles=%d clusters: %@",
373
- renderItems.count, singleCount, detailStr)
374
- #endif
375
-
376
- if strategy == .hideonoverlap {
377
- visibleItems = resolveOverlaps(
378
- items: renderItems,
379
- zoom: Double(zoom),
380
- mapWidth: mapSize.width
381
- )
382
- #if DEBUG
383
- NSLog("[Clustering] hideOnOverlap: %d → %d items",
384
- renderItems.count, visibleItems.count)
385
- #endif
386
- }
387
- // For .supercluster strategy: render all items as-is (overlaps tolerated)
388
-
389
- // --- Phase 3: Render — add new markers BEFORE removing old ones ---
390
- // This prevents the visual flash that occurs when markers are destroyed
391
- // then recreated. By adding first, the map is never empty.
392
- let isFirstRender = renderedClusterMarkers.isEmpty && renderedSingleMarkers.isEmpty
393
-
394
- // Stash old markers for removal after new ones are on the map
395
- let oldClusterMarkers = renderedClusterMarkers
396
- let oldSingleMarkers = renderedSingleMarkers
397
- renderedClusterMarkers = []
398
- renderedSingleMarkers = [:]
399
-
400
- // Restore previously hidden non-clustered markers
401
- for id in hiddenNonClusteredIds {
402
- nonClusteredMarkers[id]?.map = gmsMapView
403
- }
404
- hiddenNonClusteredIds.removeAll()
405
-
406
- // Render new items (add to map)
407
- for (renderIndex, item) in visibleItems.enumerated() {
408
- switch item.kind {
409
- case .cluster(let clusterData):
410
- let marker = GMSMarker()
411
- marker.position = clusterData.coordinate
412
- marker.groundAnchor = CGPoint(x: 0.5, y: 0.5)
413
-
414
- if let icon = clusteringManager.clusterIcon(forCount: clusterData.count) {
415
- marker.icon = icon
416
- }
417
-
418
- marker.userData = ClusterUserData(
419
- markerIds: clusterData.markerIds,
420
- count: clusterData.count
421
- )
422
-
423
- // Larger clusters render on top for consistent visual ordering
424
- marker.zIndex = Int32(clusterData.count + 1000)
425
-
426
- // Only animate on the very first render, not on pan/zoom re-renders
427
- if isFirstRender, let animationStyle = clusterConfig?.animationStyle {
428
- applyClusterAnimation(marker, style: animationStyle)
429
- }
430
-
431
- marker.map = gmsMapView
432
- renderedClusterMarkers.append(marker)
433
-
434
- case .single(let markerId):
435
- guard let data = clusterableMarkerData[markerId] else { continue }
436
- // Reuse the existing GMSMarker if this markerId was already rendered.
437
- // This avoids the remove+add cycle that causes visual flashing.
438
- if let existing = oldSingleMarkers[markerId] {
439
- updateGMSMarkerProperties(existing, from: data)
440
- existing.zIndex = Int32(renderIndex)
441
- renderedSingleMarkers[markerId] = existing
442
- } else {
443
- let marker = GMSMarker()
444
- configureGMSMarker(marker, from: data)
445
- marker.zIndex = Int32(renderIndex)
446
- marker.map = gmsMapView
447
- renderedSingleMarkers[markerId] = marker
448
- }
449
- }
450
- }
451
-
452
- // NOW remove old markers (after new ones are visible)
453
- for marker in oldClusterMarkers {
454
- marker.map = nil
455
- }
456
- for (id, marker) in oldSingleMarkers {
457
- // Skip markers that were reused in the new render
458
- if renderedSingleMarkers[id] != nil { continue }
459
- marker.map = nil
460
- }
461
-
462
- // Handle non-clustered markers with hideOnOverlap
463
- if strategy == .hideonoverlap {
464
- hideOverlappingNonClusteredMarkers(
465
- visibleItems: visibleItems,
466
- zoom: Double(zoom),
467
- mapWidth: mapSize.width
468
- )
469
- }
470
- }
471
-
472
- // MARK: - Overlap Resolution (hideOnOverlap strategy)
473
-
474
- /// Removes lower-priority items that visually overlap higher-priority items.
475
- /// Uses Mercator projection math — no GMSProjection dependency.
476
- private func resolveOverlaps(
477
- items: [RenderItem],
478
- zoom: Double,
479
- mapWidth: CGFloat
480
- ) -> [RenderItem] {
481
- let sorted = items.sorted { $0.priority > $1.priority }
482
- var visible: [RenderItem] = []
483
- visible.reserveCapacity(sorted.count)
484
-
485
- let worldSize = 256.0 * pow(2.0, zoom)
486
-
487
- for item in sorted {
488
- var hasOverlap = false
489
- for existing in visible {
490
- if itemsOverlap(item, existing, worldSize: worldSize) {
491
- hasOverlap = true
492
- break
493
- }
494
- }
495
- if !hasOverlap {
496
- visible.append(item)
497
- }
498
- }
499
-
500
- return visible
501
- }
502
-
503
- /// Hides non-clustered markers that overlap with already-rendered items.
504
- private func hideOverlappingNonClusteredMarkers(
505
- visibleItems: [RenderItem],
506
- zoom: Double,
507
- mapWidth: CGFloat
508
- ) {
509
- let worldSize = 256.0 * pow(2.0, zoom)
510
-
511
- for (id, ncMarker) in nonClusteredMarkers {
512
- let data = clusterableMarkerData[id]
513
- let icon = data.flatMap { MarkerIconFactory.createIcon(for: $0) }
514
- let size = icon?.size ?? CGSize(width: Self.defaultMarkerSize, height: Self.defaultMarkerSize)
515
-
516
- let ncItem = RenderItem(
517
- coordinate: ncMarker.position,
518
- kind: .single(markerId: id),
519
- iconSize: size,
520
- priority: 0
521
- )
522
-
523
- var hasOverlap = false
524
- for existing in visibleItems {
525
- if itemsOverlap(ncItem, existing, worldSize: worldSize) {
526
- hasOverlap = true
527
- break
528
- }
529
- }
530
-
531
- if hasOverlap {
532
- ncMarker.map = nil
533
- hiddenNonClusteredIds.insert(id)
534
- }
535
- }
536
- }
537
-
538
- /// Checks if two render items overlap in screen space using Mercator projection.
539
- private func itemsOverlap(_ a: RenderItem, _ b: RenderItem, worldSize: Double) -> Bool {
540
- let dx: Double = mercatorX(a.coordinate.longitude, worldSize: worldSize)
541
- - mercatorX(b.coordinate.longitude, worldSize: worldSize)
542
- let dy: Double = mercatorY(a.coordinate.latitude, worldSize: worldSize)
543
- - mercatorY(b.coordinate.latitude, worldSize: worldSize)
544
- let minSepX: Double = Double(a.iconSize.width + b.iconSize.width) / 2.0
545
- let minSepY: Double = Double(a.iconSize.height + b.iconSize.height) / 2.0
546
- return Swift.abs(dx) < minSepX && Swift.abs(dy) < minSepY
547
- }
548
-
549
- // MARK: - Mercator Math Helpers
550
-
551
- private func mercatorX(_ longitude: Double, worldSize: Double) -> Double {
552
- return (longitude + 180.0) / 360.0 * worldSize
553
- }
554
-
555
- private func mercatorY(_ latitude: Double, worldSize: Double) -> Double {
556
- let latRad = latitude * .pi / 180.0
557
- return (1.0 - log(tan(latRad) + 1.0 / cos(latRad)) / .pi) / 2.0 * worldSize
558
- }
559
-
560
- /// Always create a fresh GMSMarker.
561
- /// Google Maps SDK has a known bug where reused markers render at incorrect
562
- /// positions after being removed and re-added to the map. Creating fresh
563
- /// markers avoids this issue entirely.
564
- private func getMarkerFromPool() -> GMSMarker {
565
- return GMSMarker()
566
- }
567
-
568
- /// Dispose of a marker by removing it from the map.
569
- /// We intentionally do NOT pool markers due to the Google Maps SDK reuse bug.
570
- private func returnMarkerToPool(_ marker: GMSMarker) {
571
- marker.map = nil
572
- }
573
-
574
- private func clearRenderedMarkersToPool() {
575
- for marker in renderedClusterMarkers {
576
- returnMarkerToPool(marker)
577
- }
578
- renderedClusterMarkers.removeAll()
579
-
580
- for (_, marker) in renderedSingleMarkers {
581
- returnMarkerToPool(marker)
582
- }
583
- renderedSingleMarkers.removeAll()
584
- }
585
-
586
- private func renderAllMarkersIndividually() {
587
- for (_, markerData) in clusterableMarkerData {
588
- if renderedSingleMarkers[markerData.id] == nil {
589
- let marker = getMarkerFromPool()
590
- configureGMSMarker(marker, from: markerData)
591
- marker.map = gmsMapView
592
- renderedSingleMarkers[markerData.id] = marker
593
- }
594
- }
595
- }
596
-
597
- /// Configure a GMSMarker with marker data (for initial setup)
598
- private func configureGMSMarker(_ gmsMarker: GMSMarker, from markerData: MarkerData) {
599
- gmsMarker.position = CLLocationCoordinate2D(
600
- latitude: markerData.coordinate.latitude,
601
- longitude: markerData.coordinate.longitude
602
- )
603
- gmsMarker.title = markerData.title
604
- gmsMarker.snippet = markerData.description
605
- gmsMarker.isDraggable = markerData.draggable
606
- gmsMarker.opacity = Float(markerData.opacity)
607
- gmsMarker.rotation = markerData.rotation
608
- gmsMarker.zIndex = Int32(markerData.zIndex)
609
- gmsMarker.groundAnchor = CGPoint(
610
- x: markerData.anchor.x,
611
- y: markerData.anchor.y
612
- )
613
- gmsMarker.userData = markerData.id
614
- gmsMarker.icon = MarkerIconFactory.createIcon(for: markerData)
615
-
616
- switch markerData.animation {
617
- case .pop:
618
- gmsMarker.appearAnimation = .pop
619
- case .fadein:
620
- gmsMarker.appearAnimation = .fadeIn
621
- case .none:
622
- gmsMarker.appearAnimation = .none
623
- }
624
- }
625
-
626
- private func applyClusterAnimation(
627
- _ marker: GMSMarker,
628
- style: ClusterAnimationStyle
629
- ) {
630
- switch style {
631
- case .bounce, .spring:
632
- marker.appearAnimation = .pop
633
- case .fade:
634
- marker.appearAnimation = .fadeIn
635
- case .scale, .default:
636
- marker.appearAnimation = .pop
637
- }
638
- }
639
-
640
- // MARK: - Marker Management
641
-
642
- func addMarker(_ marker: MarkerData) {
643
- // FIX #3: NitroMap dispatches to main. Provider assumes main thread.
644
- addMarkerSync(marker)
645
- }
646
-
647
- func addMarkers(_ markers: [MarkerData]) {
648
- for marker in markers {
649
- addMarkerSync(marker)
650
- }
651
- performClustering()
652
- }
653
-
654
- private func addMarkerSync(_ markerData: MarkerData) {
655
- removeMarkerSync(markerData.id)
656
-
657
- // ALL markers go into the clustering engine so it knows their positions
658
- // and sizes for overlap prevention
659
- clusterableMarkerData[markerData.id] = markerData
660
- clusteringManager.addMarker(markerData)
661
-
662
- if !markerData.clusteringEnabled || !(clusterConfig?.enabled ?? true) {
663
- // Non-clustered markers also get a direct GMSMarker on the map
664
- let marker = getMarkerFromPool()
665
- configureGMSMarker(marker, from: markerData)
666
- marker.map = gmsMapView
667
- nonClusteredMarkers[markerData.id] = marker
668
- }
669
- }
670
-
671
- func updateMarker(_ marker: MarkerData) {
672
- updateMarkerInPlace(marker)
673
- }
674
-
675
- /// Update marker in-place without triggering full re-clustering
676
- /// This prevents texture allocation exhaustion by reusing existing GMSMarker objects
677
- private func updateMarkerInPlace(_ markerData: MarkerData) {
678
- let id = markerData.id
679
-
680
- // Update stored data
681
- if let existing = clusterableMarkerData[id] {
682
- clusterableMarkerData[id] = markerData
683
-
684
- // Only re-index in the C++ engine if coordinate changed.
685
- // Cosmetic updates (selection, clusteringEnabled, icon style) don't
686
- // need a spatial re-index and must NOT set markersChanged_ = true,
687
- // which would force a full hierarchy rebuild.
688
- let coordChanged =
689
- existing.coordinate.latitude != markerData.coordinate.latitude ||
690
- existing.coordinate.longitude != markerData.coordinate.longitude
691
- if coordChanged {
692
- clusteringManager.removeMarker(id)
693
- clusteringManager.addMarker(markerData)
694
- }
695
- }
696
-
697
- // Update rendered marker in-place (no remove/re-add)
698
- if let gmsMarker = renderedSingleMarkers[id] {
699
- updateGMSMarkerProperties(gmsMarker, from: markerData)
700
- } else if let gmsMarker = nonClusteredMarkers[id] {
701
- updateGMSMarkerProperties(gmsMarker, from: markerData)
702
- }
703
- // If marker not in any dict, it will be created with new data when clustering runs
704
- }
705
-
706
- /// Update GMSMarker properties in-place without creating a new marker object
707
- private func updateGMSMarkerProperties(_ gmsMarker: GMSMarker, from markerData: MarkerData) {
708
- gmsMarker.position = CLLocationCoordinate2D(
709
- latitude: markerData.coordinate.latitude,
710
- longitude: markerData.coordinate.longitude
711
- )
712
- gmsMarker.title = markerData.title
713
- gmsMarker.snippet = markerData.description
714
- gmsMarker.isDraggable = markerData.draggable
715
- gmsMarker.opacity = Float(markerData.opacity)
716
- gmsMarker.rotation = markerData.rotation
717
- gmsMarker.zIndex = Int32(markerData.zIndex)
718
- gmsMarker.groundAnchor = CGPoint(
719
- x: markerData.anchor.x,
720
- y: markerData.anchor.y
721
- )
722
-
723
- // Only regenerate icon if needed - cache will handle deduplication
724
- if let newIcon = MarkerIconFactory.createIcon(for: markerData) {
725
- gmsMarker.icon = newIcon
726
- }
727
- }
728
-
729
- func removeMarker(_ id: String) {
730
- removeMarkerSync(id)
731
- performClustering()
732
- }
733
-
734
- private func removeMarkerSync(_ id: String) {
735
- clusteringManager.removeMarker(id)
736
- clusterableMarkerData.removeValue(forKey: id)
737
-
738
- if let marker = renderedSingleMarkers[id] {
739
- marker.map = nil
740
- renderedSingleMarkers.removeValue(forKey: id)
741
- }
742
-
743
- if let marker = nonClusteredMarkers[id] {
744
- marker.map = nil
745
- nonClusteredMarkers.removeValue(forKey: id)
746
- }
747
- }
748
-
749
- func clearMarkers() {
750
- clearMarkersSync()
751
- }
752
-
753
- private func clearMarkersSync() {
754
- clusteringManager.clearMarkers()
755
- clusterableMarkerData.removeAll()
756
- clearRenderedMarkersToPool()
757
-
758
- for (_, marker) in nonClusteredMarkers {
759
- returnMarkerToPool(marker)
760
- }
761
- nonClusteredMarkers.removeAll()
762
- }
763
-
764
- func selectMarker(_ id: String) {
765
- // Deselect previous marker if exists
766
- if let previousId = selectedMarkerId, previousId != id {
767
- updateMarkerSelectionState(previousId, selected: false)
768
- }
769
-
770
- // Select new marker
771
- selectedMarkerId = id
772
- updateMarkerSelectionState(id, selected: true)
773
-
774
- // Get marker position and animate to it
775
- var markerPosition: CLLocationCoordinate2D?
776
-
777
- if let marker = renderedSingleMarkers[id] {
778
- gmsMapView.selectedMarker = marker
779
- markerPosition = marker.position
780
- } else if let marker = nonClusteredMarkers[id] {
781
- gmsMapView.selectedMarker = marker
782
- markerPosition = marker.position
783
- } else if let markerData = clusterableMarkerData[id] {
784
- // Marker is inside a cluster — create a temporary single marker
785
- let marker = GMSMarker()
786
- configureGMSMarker(marker, from: markerData)
787
- marker.map = gmsMapView
788
- renderedSingleMarkers[id] = marker
789
- gmsMapView.selectedMarker = marker
790
- markerPosition = marker.position
791
- performClustering()
792
- }
793
-
794
- // Animate camera to marker position
795
- if let position = markerPosition {
796
- let camera = GMSCameraPosition.camera(
797
- withLatitude: position.latitude,
798
- longitude: position.longitude,
799
- zoom: gmsMapView.camera.zoom
800
- )
801
- CATransaction.begin()
802
- CATransaction.setAnimationDuration(0.3)
803
- gmsMapView.animate(to: camera)
804
- CATransaction.commit()
805
- }
806
- }
807
-
808
- /// Update a marker's visual selection state by regenerating its icon
809
- private func updateMarkerSelectionState(_ id: String, selected: Bool) {
810
- guard let data = clusterableMarkerData[id] else { return }
811
-
812
- // Use injected handler to create updated marker data with selection state
813
- guard let updatedData = selectionHandler.updateSelectionState(for: data, selected: selected) else {
814
- return
815
- }
816
-
817
- // Update stored data
818
- clusterableMarkerData[id] = updatedData
819
- clusteringManager.removeMarker(id)
820
- clusteringManager.addMarker(updatedData)
821
-
822
- // Regenerate icon and apply to GMSMarker
823
- if let gmsMarker = renderedSingleMarkers[id] {
824
- gmsMarker.icon = MarkerIconFactory.createIcon(for: updatedData)
825
- } else if let gmsMarker = nonClusteredMarkers[id] {
826
- gmsMarker.icon = MarkerIconFactory.createIcon(for: updatedData)
827
- }
828
- }
829
-
830
- // MARK: - Clustering Control
831
-
832
- func setClusteringEnabled(_ enabled: Bool) {
833
- self.clusterConfig = ClusterConfig.withEnabled(enabled, existing: clusterConfig)
834
- performClustering()
835
- }
836
-
837
- func refreshClusters() {
838
- performClustering()
839
- }
840
-
841
- // MARK: - Map Style
842
-
843
- func setMapStyle(_ style: [MapStyleElement]?) {
844
- customMapStyle = style
845
- }
846
-
847
- func setDarkMode(_ enabled: Bool) {
848
- darkMode = enabled
849
- }
850
-
851
- private func applyMapStyle() {
852
- guard let styleElements = customMapStyle, !styleElements.isEmpty else {
853
- gmsMapView.mapStyle = nil
854
- return
855
- }
856
-
857
- do {
858
- let jsonArray = styleElements.map { element -> [String: Any] in
859
- var dict: [String: Any] = [:]
860
- if let featureType = element.featureType {
861
- dict["featureType"] = featureType
862
- }
863
- if let elementType = element.elementType {
864
- dict["elementType"] = elementType
865
- }
866
-
867
- dict["stylers"] = element.stylers.map { styler -> [String: Any] in
868
- var s: [String: Any] = [:]
869
- if let color = styler.color { s["color"] = color }
870
- if let visibility = styler.visibility { s["visibility"] = visibility }
871
- if let weight = styler.weight { s["weight"] = weight }
872
- if let saturation = styler.saturation { s["saturation"] = saturation }
873
- if let lightness = styler.lightness { s["lightness"] = lightness }
874
- if let gamma = styler.gamma { s["gamma"] = gamma }
875
- return s
876
- }
877
- return dict
878
- }
879
-
880
- let jsonData = try JSONSerialization.data(withJSONObject: jsonArray)
881
- let jsonString = String(data: jsonData, encoding: .utf8) ?? "[]"
882
- gmsMapView.mapStyle = try GMSMapStyle(jsonString: jsonString)
883
- } catch {
884
- // FIX #12: Surface errors via onError instead of silent print
885
- onError?(MapError(code: "STYLE_ERROR", message: "Failed to apply map style: \(error.localizedDescription)"))
886
- }
887
- }
888
-
889
- private func applyDarkMode() {
890
- if let isDark = darkMode {
891
- gmsMapView.overrideUserInterfaceStyle = isDark ? .dark : .light
892
- }
893
- }
894
-
895
- // MARK: - Helper Methods
896
-
897
- private func updateCameraToInitialRegion() {
898
- guard let region = initialRegion else { return }
899
-
900
- let camera = GMSCameraPosition.camera(
901
- withLatitude: region.latitude,
902
- longitude: region.longitude,
903
- zoom: calculateZoom(for: region)
904
- )
905
- gmsMapView.animate(to: camera)
906
- }
907
-
908
- func calculateZoom(for region: Region) -> Float {
909
- // FIX #5: Use MapStyleProvider's logarithmic calculation
910
- return Float(MapStyleProvider.shared.zoomFromDeltas(
911
- latDelta: region.latitudeDelta,
912
- lonDelta: region.longitudeDelta
913
- ))
914
- }
915
-
916
- private func convertMapType(_ type: MapType?) -> GMSMapViewType {
917
- switch type {
918
- case .satellite: return .satellite
919
- case .hybrid: return .hybrid
920
- case .standard, .none: return .normal
921
- }
922
- }
923
-
924
- }