expo-gaode-map 2.2.15 → 2.2.16
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/android/build.gradle +1 -1
- package/android/src/main/java/expo/modules/gaodemap/ExpoGaodeMapView.kt +40 -1
- package/android/src/main/java/expo/modules/gaodemap/overlays/ClusterView.kt +427 -61
- package/android/src/main/java/expo/modules/gaodemap/overlays/ClusterViewModule.kt +16 -0
- package/android/src/main/java/expo/modules/gaodemap/overlays/HeatMapView.kt +160 -25
- package/android/src/main/java/expo/modules/gaodemap/overlays/HeatMapViewModule.kt +13 -1
- package/android/src/main/java/expo/modules/gaodemap/overlays/MultiPointView.kt +165 -13
- package/android/src/main/java/expo/modules/gaodemap/overlays/MultiPointViewModule.kt +9 -1
- package/android/src/main/java/expo/modules/gaodemap/utils/BitmapDescriptorCache.kt +20 -0
- package/build/components/overlays/Cluster.d.ts.map +1 -1
- package/build/components/overlays/Cluster.js +6 -2
- package/build/components/overlays/Cluster.js.map +1 -1
- package/build/components/overlays/HeatMap.d.ts.map +1 -1
- package/build/components/overlays/HeatMap.js +12 -1
- package/build/components/overlays/HeatMap.js.map +1 -1
- package/build/index.d.ts +0 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js.map +1 -1
- package/build/types/overlays.types.d.ts +69 -14
- package/build/types/overlays.types.d.ts.map +1 -1
- package/build/types/overlays.types.js.map +1 -1
- package/build/utils/ModuleLoader.js +1 -1
- package/build/utils/ModuleLoader.js.map +1 -1
- package/ios/ExpoGaodeMapView.swift +44 -0
- package/ios/overlays/ClusterAnnotation.swift +32 -0
- package/ios/overlays/ClusterView.swift +251 -45
- package/ios/overlays/ClusterViewModule.swift +14 -0
- package/ios/overlays/CoordinateQuadTree.swift +291 -0
- package/ios/overlays/HeatMapView.swift +119 -5
- package/ios/overlays/HeatMapViewModule.swift +13 -1
- package/ios/overlays/MultiPointView.swift +160 -2
- package/ios/overlays/MultiPointViewModule.swift +22 -0
- package/package.json +1 -1
|
@@ -2,84 +2,290 @@ import ExpoModulesCore
|
|
|
2
2
|
import MAMapKit
|
|
3
3
|
|
|
4
4
|
class ClusterView: ExpoView {
|
|
5
|
-
|
|
6
|
-
var
|
|
7
|
-
|
|
5
|
+
// 属性
|
|
6
|
+
var points: [[String: Any]] = [] {
|
|
7
|
+
didSet {
|
|
8
|
+
buildQuadTree()
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
var radius: Int = 100 // 聚合范围 (screen points)
|
|
12
|
+
var minClusterSize: Int = 1
|
|
13
|
+
var clusterBuckets: [[String: Any]]?
|
|
14
|
+
|
|
15
|
+
// 样式属性
|
|
16
|
+
private var clusterBackgroundColor: UIColor = .systemBlue
|
|
17
|
+
private var clusterBorderColor: UIColor = .white
|
|
18
|
+
private var clusterBorderWidth: CGFloat = 2.0
|
|
19
|
+
private var clusterTextColor: UIColor = .white
|
|
20
|
+
private var clusterTextSize: CGFloat = 14.0
|
|
21
|
+
private var clusterSize: CGSize = CGSize(width: 40, height: 40)
|
|
22
|
+
|
|
23
|
+
let onClusterPress = EventDispatcher()
|
|
8
24
|
|
|
9
25
|
private var mapView: MAMapView?
|
|
10
|
-
private var
|
|
26
|
+
private var quadTree = CoordinateQuadTree()
|
|
27
|
+
private var currentAnnotations: [MAAnnotation] = []
|
|
28
|
+
private let quadTreeQueue = DispatchQueue(label: "com.expo.gaode.quadtree")
|
|
11
29
|
|
|
12
30
|
required init(appContext: AppContext? = nil) {
|
|
13
31
|
super.init(appContext: appContext)
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
|
|
17
|
-
self.mapView = map
|
|
18
|
-
updateCluster()
|
|
19
|
-
}
|
|
34
|
+
// MARK: - Setters for Expo Module
|
|
20
35
|
|
|
21
36
|
func setPoints(_ points: [[String: Any]]) {
|
|
22
37
|
self.points = points
|
|
23
|
-
updateCluster()
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
func setRadius(_ radius: Int) {
|
|
27
41
|
self.radius = radius
|
|
28
|
-
|
|
42
|
+
updateClusters()
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
func setMinClusterSize(_ size: Int) {
|
|
32
46
|
self.minClusterSize = size
|
|
33
|
-
|
|
47
|
+
updateClusters()
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
|
|
37
|
-
|
|
50
|
+
func setMap(_ map: MAMapView) {
|
|
51
|
+
self.mapView = map
|
|
52
|
+
updateClusters()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func setClusterStyle(_ style: [String: Any]) {
|
|
56
|
+
if let color = ColorParser.parseColor(style["backgroundColor"]) {
|
|
57
|
+
self.clusterBackgroundColor = color
|
|
58
|
+
}
|
|
59
|
+
if let borderColor = ColorParser.parseColor(style["borderColor"]) {
|
|
60
|
+
self.clusterBorderColor = borderColor
|
|
61
|
+
}
|
|
62
|
+
if let borderWidth = style["borderWidth"] as? Double {
|
|
63
|
+
self.clusterBorderWidth = CGFloat(borderWidth)
|
|
64
|
+
}
|
|
38
65
|
|
|
39
|
-
//
|
|
40
|
-
|
|
66
|
+
// 尺寸设置
|
|
67
|
+
if let width = style["width"] as? Double {
|
|
68
|
+
self.clusterSize.width = CGFloat(width)
|
|
69
|
+
// 如果只设置了宽度,默认高度等于宽度(正圆)
|
|
70
|
+
if style["height"] == nil {
|
|
71
|
+
self.clusterSize.height = CGFloat(width)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
41
74
|
|
|
42
|
-
|
|
43
|
-
|
|
75
|
+
if let height = style["height"] as? Double {
|
|
76
|
+
self.clusterSize.height = CGFloat(height)
|
|
77
|
+
// 如果只设置了高度,默认宽度等于高度
|
|
78
|
+
if style["width"] == nil {
|
|
79
|
+
self.clusterSize.width = CGFloat(height)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
44
82
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
83
|
+
updateClusters()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func setClusterTextStyle(_ style: [String: Any]) {
|
|
87
|
+
if let color = ColorParser.parseColor(style["color"]) {
|
|
88
|
+
self.clusterTextColor = color
|
|
89
|
+
}
|
|
90
|
+
if let fontSize = style["fontSize"] as? Double {
|
|
91
|
+
self.clusterTextSize = CGFloat(fontSize)
|
|
92
|
+
}
|
|
93
|
+
updateClusters()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func setClusterBuckets(_ buckets: [[String: Any]]) {
|
|
97
|
+
self.clusterBuckets = buckets
|
|
98
|
+
updateClusters()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func mapRegionDidChange() {
|
|
102
|
+
updateClusters()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - QuadTree Logic
|
|
106
|
+
|
|
107
|
+
private func buildQuadTree() {
|
|
108
|
+
// 在后台串行队列构建四叉树,保证线程安全
|
|
109
|
+
quadTreeQueue.async { [weak self] in
|
|
110
|
+
guard let self = self else { return }
|
|
111
|
+
self.quadTree.clear()
|
|
112
|
+
self.quadTree.build(with: self.points)
|
|
113
|
+
|
|
114
|
+
// 构建完成后触发更新
|
|
115
|
+
DispatchQueue.main.async {
|
|
116
|
+
self.updateClusters()
|
|
49
117
|
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// MARK: - Update Logic
|
|
122
|
+
|
|
123
|
+
func updateClusters() {
|
|
124
|
+
guard let mapView = mapView else { return }
|
|
125
|
+
|
|
126
|
+
// 确保地图已布局
|
|
127
|
+
if mapView.bounds.size.width == 0 { return }
|
|
128
|
+
|
|
129
|
+
let visibleRect = mapView.visibleMapRect
|
|
130
|
+
let boundsWidth = Double(mapView.bounds.size.width)
|
|
131
|
+
let zoomScale = boundsWidth > 0 ? visibleRect.size.width / boundsWidth : 0
|
|
132
|
+
let currentRadius = Double(self.radius)
|
|
133
|
+
|
|
134
|
+
// 在后台串行队列计算聚合
|
|
135
|
+
quadTreeQueue.async { [weak self] in
|
|
136
|
+
guard let self = self else { return }
|
|
50
137
|
|
|
51
|
-
let
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
138
|
+
let annotations = self.quadTree.clusteredAnnotations(within: visibleRect, zoomScale: zoomScale, gridSize: currentRadius)
|
|
139
|
+
|
|
140
|
+
DispatchQueue.main.async {
|
|
141
|
+
self.updateMapViewAnnotations(with: annotations as [MAAnnotation])
|
|
142
|
+
}
|
|
55
143
|
}
|
|
56
144
|
}
|
|
57
145
|
|
|
58
|
-
|
|
59
|
-
* 移除所有标注
|
|
60
|
-
*/
|
|
61
|
-
private func removeAllAnnotations() {
|
|
146
|
+
private func updateMapViewAnnotations(with newAnnotations: [MAAnnotation]) {
|
|
62
147
|
guard let mapView = mapView else { return }
|
|
63
148
|
|
|
64
|
-
|
|
65
|
-
|
|
149
|
+
// Diff 算法:找出新增、移除和保留的标注
|
|
150
|
+
// 注意:ClusterAnnotation 需要实现 isEqual 和 hash
|
|
151
|
+
|
|
152
|
+
let before = Set(currentAnnotations.compactMap { $0 as? ClusterAnnotation })
|
|
153
|
+
let after = Set(newAnnotations.compactMap { $0 as? ClusterAnnotation })
|
|
154
|
+
|
|
155
|
+
// intersection 返回 before 中的元素(如果相等),这正是我们需要保留的已经在地图上的实例
|
|
156
|
+
let toKeep = before.intersection(after)
|
|
157
|
+
let toAdd = after.subtracting(toKeep)
|
|
158
|
+
let toRemove = before.subtracting(toKeep)
|
|
159
|
+
|
|
160
|
+
// 只有当有变化时才操作
|
|
161
|
+
if !toRemove.isEmpty {
|
|
162
|
+
mapView.removeAnnotations(Array(toRemove) as [MAAnnotation])
|
|
66
163
|
}
|
|
67
|
-
|
|
164
|
+
|
|
165
|
+
if !toAdd.isEmpty {
|
|
166
|
+
mapView.addAnnotations(Array(toAdd) as [MAAnnotation])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 更新 currentAnnotations
|
|
170
|
+
// 关键:必须保留已经在地图上的实例 (toKeep),加上新增的实例 (toAdd)
|
|
171
|
+
// 这样可以保证 currentAnnotations 中的对象始终与地图上的对象一致,避免 KVO 崩溃
|
|
172
|
+
var nextAnnotations: [MAAnnotation] = []
|
|
173
|
+
nextAnnotations.append(contentsOf: Array(toKeep) as [MAAnnotation])
|
|
174
|
+
nextAnnotations.append(contentsOf: Array(toAdd) as [MAAnnotation])
|
|
175
|
+
|
|
176
|
+
currentAnnotations = nextAnnotations
|
|
68
177
|
}
|
|
69
178
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
179
|
+
// MARK: - View Provider
|
|
180
|
+
|
|
181
|
+
func viewForAnnotation(_ annotation: MAAnnotation) -> MAAnnotationView? {
|
|
182
|
+
guard let clusterAnnotation = annotation as? ClusterAnnotation else { return nil }
|
|
183
|
+
|
|
184
|
+
let reuseIdentifier = "ClusterAnnotation"
|
|
185
|
+
var annotationView = mapView?.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
|
|
186
|
+
|
|
187
|
+
if annotationView == nil {
|
|
188
|
+
annotationView = MAAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
annotationView?.annotation = annotation
|
|
192
|
+
annotationView?.canShowCallout = false
|
|
193
|
+
|
|
194
|
+
// 生成图标
|
|
195
|
+
annotationView?.image = image(for: clusterAnnotation.count)
|
|
196
|
+
annotationView?.centerOffset = CGPoint(x: 0, y: 0)
|
|
197
|
+
annotationView?.zIndex = 100
|
|
198
|
+
|
|
199
|
+
return annotationView
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private func image(for count: Int) -> UIImage? {
|
|
203
|
+
let size = self.clusterSize
|
|
204
|
+
let renderer = UIGraphicsImageRenderer(size: size)
|
|
205
|
+
|
|
206
|
+
return renderer.image { context in
|
|
207
|
+
let rect = CGRect(origin: .zero, size: size)
|
|
208
|
+
|
|
209
|
+
// 基础样式
|
|
210
|
+
var bgColor = self.clusterBackgroundColor
|
|
211
|
+
var borderColor = self.clusterBorderColor
|
|
212
|
+
var borderWidth = self.clusterBorderWidth
|
|
213
|
+
|
|
214
|
+
// 应用分级样式
|
|
215
|
+
if let buckets = self.clusterBuckets {
|
|
216
|
+
var bestBucket: [String: Any]?
|
|
217
|
+
var maxMinPoints = -1
|
|
218
|
+
|
|
219
|
+
for bucket in buckets {
|
|
220
|
+
if let minPoints = bucket["minPoints"] as? Int, minPoints <= count {
|
|
221
|
+
if minPoints > maxMinPoints {
|
|
222
|
+
maxMinPoints = minPoints
|
|
223
|
+
bestBucket = bucket
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if let bucket = bestBucket {
|
|
229
|
+
if let c = ColorParser.parseColor(bucket["backgroundColor"]) {
|
|
230
|
+
bgColor = c
|
|
231
|
+
}
|
|
232
|
+
if let c = ColorParser.parseColor(bucket["borderColor"]) {
|
|
233
|
+
borderColor = c
|
|
234
|
+
}
|
|
235
|
+
if let w = bucket["borderWidth"] as? Double {
|
|
236
|
+
borderWidth = CGFloat(w)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
bgColor.setFill()
|
|
242
|
+
UIBezierPath(ovalIn: rect).fill()
|
|
243
|
+
|
|
244
|
+
// 绘制边框
|
|
245
|
+
if borderWidth > 0 {
|
|
246
|
+
borderColor.setStroke()
|
|
247
|
+
let path = UIBezierPath(ovalIn: rect.insetBy(dx: borderWidth / 2, dy: borderWidth / 2))
|
|
248
|
+
path.lineWidth = borderWidth
|
|
249
|
+
path.stroke()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 绘制文字
|
|
253
|
+
let text = "\(count)"
|
|
254
|
+
let attributes: [NSAttributedString.Key: Any] = [
|
|
255
|
+
.font: UIFont.boldSystemFont(ofSize: self.clusterTextSize),
|
|
256
|
+
.foregroundColor: self.clusterTextColor
|
|
257
|
+
]
|
|
258
|
+
let textSize = text.size(withAttributes: attributes)
|
|
259
|
+
let textRect = CGRect(x: (size.width - textSize.width) / 2,
|
|
260
|
+
y: (size.height - textSize.height) / 2,
|
|
261
|
+
width: textSize.width,
|
|
262
|
+
height: textSize.height)
|
|
263
|
+
text.draw(in: textRect, withAttributes: attributes)
|
|
264
|
+
}
|
|
76
265
|
}
|
|
77
266
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
267
|
+
// MARK: - Event Handling
|
|
268
|
+
|
|
269
|
+
func containsAnnotation(_ annotation: MAAnnotation) -> Bool {
|
|
270
|
+
guard let clusterAnnotation = annotation as? ClusterAnnotation else { return false }
|
|
271
|
+
return currentAnnotations.contains { $0.isEqual(clusterAnnotation) }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func handleAnnotationTap(_ annotation: MAAnnotation) {
|
|
275
|
+
guard let clusterAnnotation = annotation as? ClusterAnnotation else { return }
|
|
276
|
+
|
|
277
|
+
onClusterPress([
|
|
278
|
+
"count": clusterAnnotation.count,
|
|
279
|
+
"latitude": clusterAnnotation.coordinate.latitude,
|
|
280
|
+
"longitude": clusterAnnotation.coordinate.longitude,
|
|
281
|
+
"pois": clusterAnnotation.pois
|
|
282
|
+
])
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// MARK: - Lifecycle
|
|
286
|
+
|
|
287
|
+
override func removeFromSuperview() {
|
|
288
|
+
super.removeFromSuperview()
|
|
289
|
+
mapView?.removeAnnotations(currentAnnotations)
|
|
84
290
|
}
|
|
85
|
-
}
|
|
291
|
+
}
|
|
@@ -5,6 +5,8 @@ public class ClusterViewModule: Module {
|
|
|
5
5
|
Name("ClusterView")
|
|
6
6
|
|
|
7
7
|
View(ClusterView.self) {
|
|
8
|
+
Events("onClusterPress")
|
|
9
|
+
|
|
8
10
|
Prop("points") { (view: ClusterView, points: [[String: Any]]) in
|
|
9
11
|
view.setPoints(points)
|
|
10
12
|
}
|
|
@@ -16,6 +18,18 @@ public class ClusterViewModule: Module {
|
|
|
16
18
|
Prop("minClusterSize") { (view: ClusterView, size: Int) in
|
|
17
19
|
view.setMinClusterSize(size)
|
|
18
20
|
}
|
|
21
|
+
|
|
22
|
+
Prop("clusterStyle") { (view: ClusterView, style: [String: Any]) in
|
|
23
|
+
view.setClusterStyle(style)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Prop("clusterTextStyle") { (view: ClusterView, style: [String: Any]) in
|
|
27
|
+
view.setClusterTextStyle(style)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Prop("clusterBuckets") { (view: ClusterView, buckets: [[String: Any]]) in
|
|
31
|
+
view.setClusterBuckets(buckets)
|
|
32
|
+
}
|
|
19
33
|
}
|
|
20
34
|
}
|
|
21
35
|
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreLocation
|
|
3
|
+
import MAMapKit
|
|
4
|
+
|
|
5
|
+
// MARK: - 数据结构定义
|
|
6
|
+
|
|
7
|
+
struct QuadTreePoint {
|
|
8
|
+
let x: Double // latitude
|
|
9
|
+
let y: Double // longitude
|
|
10
|
+
let data: [String: Any]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
struct BoundingBox {
|
|
14
|
+
let minX: Double // min latitude
|
|
15
|
+
let minY: Double // min longitude
|
|
16
|
+
let maxX: Double // max latitude
|
|
17
|
+
let maxY: Double // max longitude
|
|
18
|
+
|
|
19
|
+
func contains(_ point: QuadTreePoint) -> Bool {
|
|
20
|
+
return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func intersects(_ other: BoundingBox) -> Bool {
|
|
24
|
+
return other.minX <= maxX && other.maxX >= minX && other.minY <= maxY && other.maxY >= minY
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private class QuadTreeNode {
|
|
29
|
+
var points: [QuadTreePoint] = []
|
|
30
|
+
var boundingBox: BoundingBox
|
|
31
|
+
var northWest: QuadTreeNode?
|
|
32
|
+
var northEast: QuadTreeNode?
|
|
33
|
+
var southWest: QuadTreeNode?
|
|
34
|
+
var southEast: QuadTreeNode?
|
|
35
|
+
|
|
36
|
+
static let capacity = 20 // 增加容量以减少树深度
|
|
37
|
+
|
|
38
|
+
init(boundingBox: BoundingBox) {
|
|
39
|
+
self.boundingBox = boundingBox
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func insert(_ point: QuadTreePoint) -> Bool {
|
|
43
|
+
if !boundingBox.contains(point) {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if points.count < QuadTreeNode.capacity && northWest == nil {
|
|
48
|
+
points.append(point)
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if northWest == nil {
|
|
53
|
+
subdivide()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if northWest!.insert(point) { return true }
|
|
57
|
+
if northEast!.insert(point) { return true }
|
|
58
|
+
if southWest!.insert(point) { return true }
|
|
59
|
+
if southEast!.insert(point) { return true }
|
|
60
|
+
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func subdivide() {
|
|
65
|
+
let xMid = (boundingBox.minX + boundingBox.maxX) / 2
|
|
66
|
+
let yMid = (boundingBox.minY + boundingBox.maxY) / 2
|
|
67
|
+
|
|
68
|
+
northWest = QuadTreeNode(boundingBox: BoundingBox(minX: xMid, minY: boundingBox.minY, maxX: boundingBox.maxX, maxY: yMid)) // Top-Left (Lat increases upwards? No, Lat is X here)
|
|
69
|
+
// Note: Lat/Lon mapping to X/Y needs care.
|
|
70
|
+
// Let's assume X=Lat, Y=Lon.
|
|
71
|
+
// North is +Lat (MaxX), South is -Lat (MinX).
|
|
72
|
+
// East is +Lon (MaxY), West is -Lon (MinY).
|
|
73
|
+
|
|
74
|
+
// Correct subdivision:
|
|
75
|
+
// North (High Lat) / South (Low Lat)
|
|
76
|
+
// East (High Lon) / West (Low Lon)
|
|
77
|
+
|
|
78
|
+
// NorthWest: Lat [Mid, Max], Lon [Min, Mid]
|
|
79
|
+
northWest = QuadTreeNode(boundingBox: BoundingBox(minX: xMid, minY: boundingBox.minY, maxX: boundingBox.maxX, maxY: yMid))
|
|
80
|
+
|
|
81
|
+
// NorthEast: Lat [Mid, Max], Lon [Mid, Max]
|
|
82
|
+
northEast = QuadTreeNode(boundingBox: BoundingBox(minX: xMid, minY: yMid, maxX: boundingBox.maxX, maxY: boundingBox.maxY))
|
|
83
|
+
|
|
84
|
+
// SouthWest: Lat [Min, Mid], Lon [Min, Mid]
|
|
85
|
+
southWest = QuadTreeNode(boundingBox: BoundingBox(minX: boundingBox.minX, minY: boundingBox.minY, maxX: xMid, maxY: yMid))
|
|
86
|
+
|
|
87
|
+
// SouthEast: Lat [Min, Mid], Lon [Mid, Max]
|
|
88
|
+
southEast = QuadTreeNode(boundingBox: BoundingBox(minX: boundingBox.minX, minY: yMid, maxX: xMid, maxY: boundingBox.maxY))
|
|
89
|
+
|
|
90
|
+
// Redistribute points
|
|
91
|
+
for p in points {
|
|
92
|
+
_ = northWest!.insert(p) || northEast!.insert(p) || southWest!.insert(p) || southEast!.insert(p)
|
|
93
|
+
}
|
|
94
|
+
points.removeAll()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func query(range: BoundingBox, results: inout [QuadTreePoint]) {
|
|
98
|
+
if !boundingBox.intersects(range) {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for p in points {
|
|
103
|
+
if range.contains(p) {
|
|
104
|
+
results.append(p)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if northWest == nil {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
northWest!.query(range: range, results: &results)
|
|
113
|
+
northEast!.query(range: range, results: &results)
|
|
114
|
+
southWest!.query(range: range, results: &results)
|
|
115
|
+
southEast!.query(range: range, results: &results)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: - QuadTree 管理类
|
|
120
|
+
|
|
121
|
+
class CoordinateQuadTree {
|
|
122
|
+
private var root: QuadTreeNode?
|
|
123
|
+
|
|
124
|
+
/// 构建四叉树
|
|
125
|
+
func build(with points: [[String: Any]]) {
|
|
126
|
+
// 全球范围
|
|
127
|
+
let worldBox = BoundingBox(minX: -90, minY: -180, maxX: 90, maxY: 180)
|
|
128
|
+
root = QuadTreeNode(boundingBox: worldBox)
|
|
129
|
+
|
|
130
|
+
for pointData in points {
|
|
131
|
+
if let lat = pointData["latitude"] as? Double,
|
|
132
|
+
let lon = pointData["longitude"] as? Double {
|
|
133
|
+
let point = QuadTreePoint(x: lat, y: lon, data: pointData)
|
|
134
|
+
_ = root?.insert(point)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// 清除
|
|
140
|
+
func clear() {
|
|
141
|
+
root = nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// 计算聚类
|
|
145
|
+
/// - Parameters:
|
|
146
|
+
/// - visibleRect: 当前可见区域 (MAMapRect)
|
|
147
|
+
/// - zoomLevel: 当前缩放级别
|
|
148
|
+
/// - gridSize: 聚合网格大小(像素),默认 50
|
|
149
|
+
func clusteredAnnotations(within visibleRect: MAMapRect, zoomLevel: Double, gridSize: Double = 50) -> [ClusterAnnotation] {
|
|
150
|
+
guard let root = root else { return [] }
|
|
151
|
+
|
|
152
|
+
// 1. 计算当前 zoomLevel 下,gridSize 对应的经纬度范围
|
|
153
|
+
// 这是一个粗略估算,因为经纬度距离随纬度变化。
|
|
154
|
+
// 为了简化,我们使用一个经验公式或 MAMapKit 提供的转换(如果可用)。
|
|
155
|
+
// 这里我们使用一个简单的比例:
|
|
156
|
+
// 缩放级别每增加1,分辨率翻倍(每像素代表的度数减半)。
|
|
157
|
+
// Zoom 3: ~30 degrees per screen? No.
|
|
158
|
+
// A standard approach in QuadTree clustering is distance-based clustering.
|
|
159
|
+
|
|
160
|
+
// 计算当前可视区域的经纬度范围
|
|
161
|
+
let northEast = MACoordinateForMapPoint(MAMapPoint(x: visibleRect.origin.x + visibleRect.size.width, y: visibleRect.origin.y))
|
|
162
|
+
let southWest = MACoordinateForMapPoint(MAMapPoint(x: visibleRect.origin.x, y: visibleRect.origin.y + visibleRect.size.height))
|
|
163
|
+
|
|
164
|
+
// 注意:MAMapRect 的坐标系原点在左上角?MAMapPoint 是投影坐标。
|
|
165
|
+
// MAMapCoordinateForMapPoint 转换是准确的。
|
|
166
|
+
|
|
167
|
+
let minLat = min(northEast.latitude, southWest.latitude)
|
|
168
|
+
let maxLat = max(northEast.latitude, southWest.latitude)
|
|
169
|
+
let minLon = min(northEast.longitude, southWest.longitude)
|
|
170
|
+
let maxLon = max(northEast.longitude, southWest.longitude)
|
|
171
|
+
|
|
172
|
+
// 扩大一点搜索范围,避免边界问题
|
|
173
|
+
let searchBox = BoundingBox(minX: minLat, minY: minLon, maxX: maxLat, maxY: maxLon)
|
|
174
|
+
|
|
175
|
+
var pointsInView: [QuadTreePoint] = []
|
|
176
|
+
root.query(range: searchBox, results: &pointsInView)
|
|
177
|
+
|
|
178
|
+
// 如果点太多,可能会卡顿。这里可以限制最大点数。
|
|
179
|
+
|
|
180
|
+
// 2. 基于距离的聚类
|
|
181
|
+
// 将 gridSize (screen pixels) 转换为经纬度距离
|
|
182
|
+
// 这需要 mapView 的 bounds 和 visibleMapRect。但这里我们只有 visibleRect 和 zoomLevel。
|
|
183
|
+
// 我们可以估算。
|
|
184
|
+
// Scale = visibleRect.size.width / mapView.bounds.size.width (MAMapPoint per pixel)
|
|
185
|
+
// 这里没有 map view bounds,只有 visibleRect。
|
|
186
|
+
// 假设 visibleRect 对应整个屏幕。
|
|
187
|
+
|
|
188
|
+
// 实际上,MAMapPoint 的单位是米(大约)。
|
|
189
|
+
// 我们可以直接在 MAMapPoint 坐标系下做聚类,这样更方便,因为它是平面的。
|
|
190
|
+
// 但是我们的 QuadTree 存储的是经纬度。
|
|
191
|
+
// 方案:将查询到的点转换为 MAMapPoint,然后进行距离聚类。
|
|
192
|
+
|
|
193
|
+
var clusters: [ClusterAnnotation] = []
|
|
194
|
+
var visited = Set<String>() // 存储已处理点的唯一标识(这里用 hash 或 index)
|
|
195
|
+
// 由于 QuadTreePoint 没有 ID,我们用 index
|
|
196
|
+
var visitedIndices = Set<Int>()
|
|
197
|
+
|
|
198
|
+
// 转换 gridSize 到 MAMapPoint 距离
|
|
199
|
+
// 整个地图宽度约 2.68亿 (MAMapSizeWorld.width)
|
|
200
|
+
// ZoomLevel 3 -> 1 pixel = large distance
|
|
201
|
+
// 我们利用传入的 zoomScale 或者直接利用 MAMetersPerMapPointAtZoomLevel? No.
|
|
202
|
+
|
|
203
|
+
// 用户传入了 zoomScale (MAMapPoint per pixel)
|
|
204
|
+
// Let's assume gridSize is 50 pixels.
|
|
205
|
+
// distance = gridSize * zoomScale
|
|
206
|
+
|
|
207
|
+
// 但是参数里没有 zoomScale,只有 visibleRect 和 zoomLevel。
|
|
208
|
+
// 我们可以推算 zoomScale。
|
|
209
|
+
// 实际上,用户示例代码传了 zoomScale:
|
|
210
|
+
// clusteredAnnotations(within: visibleRect, withZoomScale: zoomScale, andZoomLevel: zoomLevel)
|
|
211
|
+
// 我应该修改方法签名以包含 zoomScale。
|
|
212
|
+
|
|
213
|
+
// 暂时无法获取 zoomScale,我们先用 zoomLevel 估算。
|
|
214
|
+
// 或者直接让调用者传入 zoomScale。
|
|
215
|
+
|
|
216
|
+
return [] // 临时返回,需要在 updateClusterView 中正确调用
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 重载带 zoomScale 的方法
|
|
220
|
+
func clusteredAnnotations(within visibleRect: MAMapRect, zoomScale: Double, gridSize: Double = 50) -> [ClusterAnnotation] {
|
|
221
|
+
guard let root = root else { return [] }
|
|
222
|
+
|
|
223
|
+
// 1. 查询可见区域内的点
|
|
224
|
+
let northEast = MACoordinateForMapPoint(MAMapPoint(x: visibleRect.origin.x + visibleRect.size.width, y: visibleRect.origin.y))
|
|
225
|
+
let southWest = MACoordinateForMapPoint(MAMapPoint(x: visibleRect.origin.x, y: visibleRect.origin.y + visibleRect.size.height))
|
|
226
|
+
|
|
227
|
+
let minLat = min(northEast.latitude, southWest.latitude)
|
|
228
|
+
let maxLat = max(northEast.latitude, southWest.latitude)
|
|
229
|
+
let minLon = min(northEast.longitude, southWest.longitude)
|
|
230
|
+
let maxLon = max(northEast.longitude, southWest.longitude)
|
|
231
|
+
|
|
232
|
+
let searchBox = BoundingBox(minX: minLat, minY: minLon, maxX: maxLat, maxY: maxLon)
|
|
233
|
+
|
|
234
|
+
var pointsInView: [QuadTreePoint] = []
|
|
235
|
+
root.query(range: searchBox, results: &pointsInView)
|
|
236
|
+
|
|
237
|
+
if pointsInView.isEmpty { return [] }
|
|
238
|
+
|
|
239
|
+
// 2. 聚类
|
|
240
|
+
// 聚类半径(MAMapPoint 单位)
|
|
241
|
+
let clusterRadius = gridSize * zoomScale
|
|
242
|
+
let clusterRadiusSq = clusterRadius * clusterRadius
|
|
243
|
+
|
|
244
|
+
var clusters: [ClusterAnnotation] = []
|
|
245
|
+
var visited = [Bool](repeating: false, count: pointsInView.count)
|
|
246
|
+
|
|
247
|
+
// 将所有点转换为 MAMapPoint 以便计算距离
|
|
248
|
+
let mapPoints = pointsInView.map { MAMapPointForCoordinate(CLLocationCoordinate2D(latitude: $0.x, longitude: $0.y)) }
|
|
249
|
+
|
|
250
|
+
for i in 0..<pointsInView.count {
|
|
251
|
+
if visited[i] { continue }
|
|
252
|
+
|
|
253
|
+
let centerPoint = pointsInView[i]
|
|
254
|
+
let centerMapPoint = mapPoints[i]
|
|
255
|
+
|
|
256
|
+
var clusterPoints: [[String: Any]] = [centerPoint.data]
|
|
257
|
+
visited[i] = true
|
|
258
|
+
|
|
259
|
+
// 查找邻居
|
|
260
|
+
for j in (i+1)..<pointsInView.count {
|
|
261
|
+
if visited[j] { continue }
|
|
262
|
+
|
|
263
|
+
let neighborMapPoint = mapPoints[j]
|
|
264
|
+
|
|
265
|
+
let dx = centerMapPoint.x - neighborMapPoint.x
|
|
266
|
+
let dy = centerMapPoint.y - neighborMapPoint.y
|
|
267
|
+
let distSq = dx*dx + dy*dy
|
|
268
|
+
|
|
269
|
+
if distSq <= clusterRadiusSq {
|
|
270
|
+
clusterPoints.append(pointsInView[j].data)
|
|
271
|
+
visited[j] = true
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if clusterPoints.count == 1 {
|
|
276
|
+
// 单个点
|
|
277
|
+
let annotation = ClusterAnnotation(coordinate: CLLocationCoordinate2D(latitude: centerPoint.x, longitude: centerPoint.y), count: 1, pois: clusterPoints)
|
|
278
|
+
clusters.append(annotation)
|
|
279
|
+
} else {
|
|
280
|
+
// 聚合点:计算平均坐标 (这里简单使用中心点坐标,或者计算所有点的平均值)
|
|
281
|
+
// 高德示例通常使用第一个点的坐标作为聚合点坐标,或者计算重心。
|
|
282
|
+
// 为了视觉稳定,使用中心点坐标可能更好?不,使用重心更准确。
|
|
283
|
+
// 简单起见,使用 centerPoint (第一个点) 的坐标。
|
|
284
|
+
let annotation = ClusterAnnotation(coordinate: CLLocationCoordinate2D(latitude: centerPoint.x, longitude: centerPoint.y), count: clusterPoints.count, pois: clusterPoints)
|
|
285
|
+
clusters.append(annotation)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return clusters
|
|
290
|
+
}
|
|
291
|
+
}
|