@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.
- package/LICENSE +1 -1
- package/NitroMap.podspec +1 -1
- package/README.md +82 -9
- package/android/CMakeLists.txt +4 -1
- package/android/gradle.properties +4 -4
- package/android/src/main/cpp/ClusterEngineJNI.cpp +198 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMap.kt +397 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMapConfig.kt +53 -0
- package/android/src/main/{java → kotlin}/com/margelo/nitro/nitromap/NitroMapPackage.kt +4 -4
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/NitroMapView.kt +73 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/UserLocationManager.kt +295 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/ClusterIconRenderer.kt +111 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/ClusteringManager.kt +104 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/clustering/NitroClusterEngine.kt +166 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/MarkerIconFactory.kt +303 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/MarkerSelectionHandler.kt +72 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/markers/PriceMarkerRenderer.kt +159 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/MapProviderFactory.kt +24 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/MapProviderInterface.kt +128 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapDelegate.kt +317 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Clustering.kt +524 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Markers.kt +358 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+Overlays.kt +272 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider+UserLocation.kt +296 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/GoogleMapProvider.kt +815 -0
- package/android/src/main/kotlin/com/margelo/nitro/nitromap/providers/google/MarkerTagData.kt +19 -0
- package/ios/Clustering/ClusterIconRenderer.swift +3 -3
- package/ios/Location/NitroLocationManager.swift +116 -0
- package/ios/MarkerRenderer/MarkerIconFactory.swift +1 -3
- package/ios/MarkerRenderer/PriceMarkerRenderer.swift +10 -6
- package/ios/NitroMap.swift +279 -13
- package/ios/NitroMapConfig/NitroMapConfig.swift +45 -0
- package/ios/Providers/{GoogleMapDelegate.swift → Google/GoogleMapDelegate.swift} +48 -23
- package/ios/Providers/Google/GoogleMapProvider+Camera.swift +180 -0
- package/ios/Providers/Google/GoogleMapProvider+Clustering.swift +541 -0
- package/ios/Providers/Google/GoogleMapProvider+Markers.swift +270 -0
- package/ios/Providers/Google/GoogleMapProvider+Overlays.swift +245 -0
- package/ios/Providers/Google/GoogleMapProvider+UserLocation.swift +180 -0
- package/ios/Providers/Google/GoogleMapProvider.swift +342 -0
- package/ios/Providers/MapProviderFactory.swift +17 -0
- package/ios/Providers/MapProviderProtocol.swift +48 -1
- package/ios/Shared/ClusterConfig+Factory.swift +2 -2
- package/ios/Shared/MapStyleProvider.swift +6 -4
- package/ios/Shared/MarkerSelectionHandler.swift +4 -1
- package/ios/Utils/ColorValueExtension.swift +46 -67
- package/lib/module/components/ImageMarker.js +39 -29
- package/lib/module/components/ImageMarker.js.map +1 -1
- package/lib/module/components/Marker.js +118 -0
- package/lib/module/components/Marker.js.map +1 -0
- package/lib/module/components/NitroCircle.js +92 -0
- package/lib/module/components/NitroCircle.js.map +1 -0
- package/lib/module/components/NitroMap.js +216 -76
- package/lib/module/components/NitroMap.js.map +1 -1
- package/lib/module/components/NitroPolygon.js +135 -0
- package/lib/module/components/NitroPolygon.js.map +1 -0
- package/lib/module/components/NitroPolyline.js +115 -0
- package/lib/module/components/NitroPolyline.js.map +1 -0
- package/lib/module/components/PriceMarker.js +16 -29
- package/lib/module/components/PriceMarker.js.map +1 -1
- package/lib/module/context/NitroMapContext.js.map +1 -1
- package/lib/module/hooks/useNitroCircle.js +18 -0
- package/lib/module/hooks/useNitroCircle.js.map +1 -0
- package/lib/module/hooks/useNitroMarker.js +26 -9
- package/lib/module/hooks/useNitroMarker.js.map +1 -1
- package/lib/module/hooks/useNitroOverlay.js +59 -0
- package/lib/module/hooks/useNitroOverlay.js.map +1 -0
- package/lib/module/hooks/useNitroPolygon.js +18 -0
- package/lib/module/hooks/useNitroPolygon.js.map +1 -0
- package/lib/module/hooks/useNitroPolyline.js +18 -0
- package/lib/module/hooks/useNitroPolyline.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/types/overlay.js +4 -0
- package/lib/module/types/overlay.js.map +1 -0
- package/lib/module/types/theme.js +4 -0
- package/lib/module/types/theme.js.map +1 -0
- package/lib/module/utils/colors.js +41 -13
- package/lib/module/utils/colors.js.map +1 -1
- package/lib/module/utils/validation.js +45 -0
- package/lib/module/utils/validation.js.map +1 -0
- package/lib/typescript/src/components/ImageMarker.d.ts.map +1 -1
- package/lib/typescript/src/components/Marker.d.ts +34 -0
- package/lib/typescript/src/components/Marker.d.ts.map +1 -0
- package/lib/typescript/src/components/NitroCircle.d.ts +70 -0
- package/lib/typescript/src/components/NitroCircle.d.ts.map +1 -0
- package/lib/typescript/src/components/NitroMap.d.ts +60 -3
- package/lib/typescript/src/components/NitroMap.d.ts.map +1 -1
- package/lib/typescript/src/components/NitroPolygon.d.ts +86 -0
- package/lib/typescript/src/components/NitroPolygon.d.ts.map +1 -0
- package/lib/typescript/src/components/NitroPolyline.d.ts +84 -0
- package/lib/typescript/src/components/NitroPolyline.d.ts.map +1 -0
- package/lib/typescript/src/components/PriceMarker.d.ts +0 -5
- package/lib/typescript/src/components/PriceMarker.d.ts.map +1 -1
- package/lib/typescript/src/context/NitroMapContext.d.ts +2 -0
- package/lib/typescript/src/context/NitroMapContext.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useNitroCircle.d.ts +7 -0
- package/lib/typescript/src/hooks/useNitroCircle.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useNitroMarker.d.ts +20 -0
- package/lib/typescript/src/hooks/useNitroMarker.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useNitroOverlay.d.ts +26 -0
- package/lib/typescript/src/hooks/useNitroOverlay.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useNitroPolygon.d.ts +7 -0
- package/lib/typescript/src/hooks/useNitroPolygon.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useNitroPolyline.d.ts +7 -0
- package/lib/typescript/src/hooks/useNitroPolyline.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +15 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/specs/NitroMap.nitro.d.ts +248 -6
- package/lib/typescript/src/specs/NitroMap.nitro.d.ts.map +1 -1
- package/lib/typescript/src/types/map.d.ts +34 -4
- package/lib/typescript/src/types/map.d.ts.map +1 -1
- package/lib/typescript/src/types/marker.d.ts +24 -36
- package/lib/typescript/src/types/marker.d.ts.map +1 -1
- package/lib/typescript/src/types/overlay.d.ts +75 -0
- package/lib/typescript/src/types/overlay.d.ts.map +1 -0
- package/lib/typescript/src/types/theme.d.ts +93 -0
- package/lib/typescript/src/types/theme.d.ts.map +1 -0
- package/lib/typescript/src/utils/colors.d.ts +6 -8
- package/lib/typescript/src/utils/colors.d.ts.map +1 -1
- package/lib/typescript/src/utils/validation.d.ts +12 -0
- package/lib/typescript/src/utils/validation.d.ts.map +1 -0
- package/nitrogen/generated/android/c++/JCircleData.hpp +94 -0
- package/nitrogen/generated/android/c++/JClusterConfig.hpp +5 -7
- package/nitrogen/generated/android/c++/JFunc_void_UserLocationChangeEvent.hpp +79 -0
- package/nitrogen/generated/android/c++/JFunc_void_UserTrackingMode.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
- package/nitrogen/generated/android/c++/JHybridNitroMapSpec.cpp +328 -21
- package/nitrogen/generated/android/c++/JHybridNitroMapSpec.hpp +53 -2
- package/nitrogen/generated/android/c++/JMarkerAnimation.hpp +3 -6
- package/nitrogen/generated/android/c++/JMarkerData.hpp +15 -3
- package/nitrogen/generated/android/c++/JPolygonData.hpp +149 -0
- package/nitrogen/generated/android/c++/JPolylineData.hpp +113 -0
- package/nitrogen/generated/android/c++/JUserLocationChangeEvent.hpp +70 -0
- package/nitrogen/generated/android/c++/JUserTrackingMode.hpp +62 -0
- package/nitrogen/generated/android/c++/views/JHybridNitroMapStateUpdater.cpp +72 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/CircleData.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/ClusterConfig.kt +4 -4
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_UserLocationChangeEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_UserTrackingMode.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/Func_void_std__string.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/HybridNitroMapSpec.kt +228 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/MarkerAnimation.kt +1 -2
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/MarkerData.kt +12 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/PolygonData.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/PolylineData.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/UserLocationChangeEvent.kt +47 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromap/{ClusterAnimationStyle.kt → UserTrackingMode.kt} +6 -8
- package/nitrogen/generated/android/nitromapOnLoad.cpp +6 -0
- package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Bridge.cpp +24 -0
- package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Bridge.hpp +175 -17
- package/nitrogen/generated/ios/NitroMap-Swift-Cxx-Umbrella.hpp +15 -3
- package/nitrogen/generated/ios/c++/HybridNitroMapSpecSwift.hpp +249 -16
- package/nitrogen/generated/ios/c++/views/HybridNitroMapComponent.mm +90 -5
- package/nitrogen/generated/ios/swift/CircleData.swift +143 -0
- package/nitrogen/generated/ios/swift/ClusterConfig.swift +22 -15
- package/nitrogen/generated/ios/swift/Func_void_UserLocationChangeEvent.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_UserTrackingMode.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridNitroMapSpec.swift +35 -1
- package/nitrogen/generated/ios/swift/HybridNitroMapSpec_cxx.swift +582 -8
- package/nitrogen/generated/ios/swift/MarkerAnimation.swift +4 -8
- package/nitrogen/generated/ios/swift/MarkerData.swift +54 -2
- package/nitrogen/generated/ios/swift/PolygonData.swift +179 -0
- package/nitrogen/generated/ios/swift/PolylineData.swift +155 -0
- package/nitrogen/generated/ios/swift/UserLocationChangeEvent.swift +69 -0
- package/nitrogen/generated/ios/swift/UserTrackingMode.swift +44 -0
- package/nitrogen/generated/shared/c++/CircleData.hpp +113 -0
- package/nitrogen/generated/shared/c++/ClusterConfig.hpp +5 -8
- package/nitrogen/generated/shared/c++/HybridNitroMapSpec.cpp +53 -2
- package/nitrogen/generated/shared/c++/HybridNitroMapSpec.hpp +75 -6
- package/nitrogen/generated/shared/c++/MarkerAnimation.hpp +4 -8
- package/nitrogen/generated/shared/c++/MarkerData.hpp +14 -2
- package/nitrogen/generated/shared/c++/PolygonData.hpp +114 -0
- package/nitrogen/generated/shared/c++/PolylineData.hpp +114 -0
- package/nitrogen/generated/shared/c++/UserLocationChangeEvent.hpp +88 -0
- package/nitrogen/generated/shared/c++/UserTrackingMode.hpp +80 -0
- package/nitrogen/generated/shared/c++/views/HybridNitroMapComponent.cpp +216 -12
- package/nitrogen/generated/shared/c++/views/HybridNitroMapComponent.hpp +23 -1
- package/nitrogen/generated/shared/json/NitroMapConfig.json +18 -1
- package/package.json +36 -5
- package/src/components/ImageMarker.tsx +58 -42
- package/src/components/Marker.tsx +161 -0
- package/src/components/NitroCircle.tsx +183 -0
- package/src/components/NitroMap.tsx +328 -78
- package/src/components/NitroPolygon.tsx +229 -0
- package/src/components/NitroPolyline.tsx +208 -0
- package/src/components/PriceMarker.tsx +23 -48
- package/src/context/NitroMapContext.tsx +4 -0
- package/src/hooks/useNitroCircle.ts +25 -0
- package/src/hooks/useNitroMarker.ts +49 -10
- package/src/hooks/useNitroOverlay.ts +68 -0
- package/src/hooks/useNitroPolygon.ts +25 -0
- package/src/hooks/useNitroPolyline.ts +25 -0
- package/src/index.tsx +23 -2
- package/src/specs/NitroMap.nitro.ts +294 -5
- package/src/types/map.ts +36 -4
- package/src/types/marker.ts +24 -44
- package/src/types/overlay.ts +77 -0
- package/src/types/theme.ts +101 -0
- package/src/utils/colors.ts +48 -16
- package/src/utils/validation.ts +69 -0
- package/android/src/main/java/com/margelo/nitro/nitromap/ClusterIconGenerator.kt +0 -108
- package/android/src/main/java/com/margelo/nitro/nitromap/ColorUtils.kt +0 -63
- package/android/src/main/java/com/margelo/nitro/nitromap/HybridNitroMap.kt +0 -408
- package/android/src/main/java/com/margelo/nitro/nitromap/HybridNitroMapConfig.kt +0 -68
- package/android/src/main/java/com/margelo/nitro/nitromap/MarkerIconCache.kt +0 -176
- package/android/src/main/java/com/margelo/nitro/nitromap/MarkerIconFactory.kt +0 -252
- package/android/src/main/java/com/margelo/nitro/nitromap/clustering/NitroClusterEngine.kt +0 -252
- package/android/src/main/java/com/margelo/nitro/nitromap/clustering/QuadTree.kt +0 -195
- package/android/src/main/java/com/margelo/nitro/nitromap/providers/GoogleMapProvider.kt +0 -912
- package/android/src/main/java/com/margelo/nitro/nitromap/providers/MapProviderInterface.kt +0 -70
- package/cpp/QuadTree.hpp +0 -246
- package/ios/NitroMapConfig/HybridNitroMapConfig.swift +0 -33
- package/ios/Providers/GoogleMapProvider+Camera.swift +0 -164
- package/ios/Providers/GoogleMapProvider.swift +0 -924
- package/nitrogen/generated/android/c++/JClusterAnimationStyle.hpp +0 -68
- package/nitrogen/generated/ios/swift/ClusterAnimationStyle.swift +0 -52
- 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
|
+
}
|