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.
Files changed (33) hide show
  1. package/android/build.gradle +1 -1
  2. package/android/src/main/java/expo/modules/gaodemap/ExpoGaodeMapView.kt +40 -1
  3. package/android/src/main/java/expo/modules/gaodemap/overlays/ClusterView.kt +427 -61
  4. package/android/src/main/java/expo/modules/gaodemap/overlays/ClusterViewModule.kt +16 -0
  5. package/android/src/main/java/expo/modules/gaodemap/overlays/HeatMapView.kt +160 -25
  6. package/android/src/main/java/expo/modules/gaodemap/overlays/HeatMapViewModule.kt +13 -1
  7. package/android/src/main/java/expo/modules/gaodemap/overlays/MultiPointView.kt +165 -13
  8. package/android/src/main/java/expo/modules/gaodemap/overlays/MultiPointViewModule.kt +9 -1
  9. package/android/src/main/java/expo/modules/gaodemap/utils/BitmapDescriptorCache.kt +20 -0
  10. package/build/components/overlays/Cluster.d.ts.map +1 -1
  11. package/build/components/overlays/Cluster.js +6 -2
  12. package/build/components/overlays/Cluster.js.map +1 -1
  13. package/build/components/overlays/HeatMap.d.ts.map +1 -1
  14. package/build/components/overlays/HeatMap.js +12 -1
  15. package/build/components/overlays/HeatMap.js.map +1 -1
  16. package/build/index.d.ts +0 -1
  17. package/build/index.d.ts.map +1 -1
  18. package/build/index.js.map +1 -1
  19. package/build/types/overlays.types.d.ts +69 -14
  20. package/build/types/overlays.types.d.ts.map +1 -1
  21. package/build/types/overlays.types.js.map +1 -1
  22. package/build/utils/ModuleLoader.js +1 -1
  23. package/build/utils/ModuleLoader.js.map +1 -1
  24. package/ios/ExpoGaodeMapView.swift +44 -0
  25. package/ios/overlays/ClusterAnnotation.swift +32 -0
  26. package/ios/overlays/ClusterView.swift +251 -45
  27. package/ios/overlays/ClusterViewModule.swift +14 -0
  28. package/ios/overlays/CoordinateQuadTree.swift +291 -0
  29. package/ios/overlays/HeatMapView.swift +119 -5
  30. package/ios/overlays/HeatMapViewModule.swift +13 -1
  31. package/ios/overlays/MultiPointView.swift +160 -2
  32. package/ios/overlays/MultiPointViewModule.swift +22 -0
  33. package/package.json +1 -1
@@ -2,84 +2,290 @@ import ExpoModulesCore
2
2
  import MAMapKit
3
3
 
4
4
  class ClusterView: ExpoView {
5
- var points: [[String: Any]] = []
6
- var radius: Int = 100
7
- var minClusterSize: Int = 2
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 annotations: [MAPointAnnotation] = []
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
- func setMap(_ map: MAMapView) {
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
- updateCluster()
42
+ updateClusters()
29
43
  }
30
44
 
31
45
  func setMinClusterSize(_ size: Int) {
32
46
  self.minClusterSize = size
33
- updateCluster()
47
+ updateClusters()
34
48
  }
35
49
 
36
- private func updateCluster() {
37
- guard let mapView = mapView else { return }
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
- removeAllAnnotations()
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
- guard !points.isEmpty else { return }
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
- for point in points {
46
- guard let latitude = point["latitude"] as? Double,
47
- let longitude = point["longitude"] as? Double else {
48
- continue
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 annotation = MAPointAnnotation()
52
- annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
53
- mapView.addAnnotation(annotation)
54
- annotations.append(annotation)
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
- for annotation in annotations {
65
- mapView.removeAnnotation(annotation)
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
- annotations.removeAll()
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
- override func removeFromSuperview() {
74
- super.removeFromSuperview()
75
- removeAllAnnotations()
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
- deinit {
82
- removeAllAnnotations()
83
- mapView = nil
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
+ }