在地圖類應用中,當需要展示大量地理興趣點時,直接將所有點渲染在地圖上會導致視覺混亂,影響用戶體驗。為此,我基于 Elasticsearch 提供的 geotile_grid
和 geo_bounding_box
查詢能力,實現了一套高效的 POI 聚合展示方案。
🧩 問題背景:POI 數量巨大,直接渲染效率低
在地圖類應用中,我們常常需要展示大量的地理興趣點(Point of Interest, POI),例如商圈、門店、用戶位置等。然而,當 POI 數量達到數萬甚至數十萬級別時,若將所有點一次性加載并渲染在地圖上,不僅會導致頁面卡頓、交互延遲,還會因標記重疊嚴重而降低用戶體驗。更嚴重的問題包括:
- 前端性能瓶頸:大量 DOM 節點或圖形元素導致瀏覽器渲染壓力劇增;
- 視覺混亂:點與點之間相互遮擋,信息難以辨識;
- 無差別展示:無法根據地圖縮放層級動態調整展示粒度;
因此,我們需要一種既能減少前端渲染壓力,又能保留關鍵信息的地圖聚合方案。
🛠? 技術方案:基于 Elasticsearch 的 geotile_grid 聚合機制
本方案基于 Elasticsearch 的 geotile_grid
和 geo_bounding_box
查詢能力,結合球面幾何算法,實現了poi高效聚合與展示。整個流程如下:
- 獲取地圖視口范圍:通過左上角和右下角坐標限定查詢范圍;
- 映射地圖縮放層級到 precision,根據當前地圖 zoom level 動態計算合適的 geotile_grid 聚合精度;
- 提取聚合點并計算代表點,對每個 tile 內最多 100 個點進行球面幾何中位數計算,得出一個最具代表性的點用于展示。
流程圖:
效果展示:
Screen-2025-07-03-163545
📌 地圖聚合的核心:geotile_grid
在本方案中,我們使用了 Elasticsearch 的 geotile_grid 聚合方式來實現地圖興趣點的分區聚合。
geotile_grid
本質上是一種基于 Geohash 編碼的空間劃分機制。它將整個地圖視圖劃分為多個大小一致的矩形區域(稱為 tile),每個 tile 包含落在其范圍內的所有 POI。
- tile 的粒度由 precision 控制:precision 越高,tile 越小,聚合越精細;
- 結合 zoom level 動態映射 precision:根據當前地圖縮放層級,動態設置合適的精度值,使聚合結果與地圖展示粒度保持一致;
- 支持子聚合操作:我們可以在每個 tile 中嵌套 top_hits 聚合,獲取最多 100 個原始點用于后續中心點計算;
通過這種方式,我們可以:
- 高效篩選出當前地圖視口內的所有 tile;
- 快速獲取每個 tile 內的 POI 數據;
- 為每個 tile 計算出最具代表性的“中心點”,用于前端展示;
- 這不僅顯著提升了后端查詢效率,也為前端提供了結構清晰、層次分明的地圖聚合展示能力。
🧑?💻 示例代碼片段
核心代碼:基于 geotile_grid 實現地圖點聚合
// 1. 構建篩選條件(僅保留地理范圍查詢)
BoolQueryBuilder queryBuilder = QueryBuilders.boolQuery();
queryBuilder.must(QueryBuilders.geoBoundingBoxQuery("latLng").setCorners(new GeoPoint(topLeftLat, topLeftLon), new GeoPoint(bottomRightLat, bottomRightLon)));// 2. 構建 geotile_grid 聚合
GeoGridAggregationBuilder geoTileGridAgg = AggregationBuilders.geotileGrid("grid_agg").field("latLng").precision(15); // 可根據縮放級別動態設置 precision// 3. 添加子聚合 top_hits(獲取每個 tile 內的 latLng 坐標)
TopHitsAggregationBuilder topHitsAgg = AggregationBuilders.topHits("top_hits_agg").fetchSource("latLng", null).size(100);
geoTileGridAgg.subAggregation(topHitsAgg);// 4. 構建最終查詢
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder().withQuery(queryBuilder).withAggregations(geoTileGridAgg).withMaxResults(0) // 不需要返回實際文檔.build();// 5. 執行查詢并解析聚合結果
SearchHits<ShopESEntity> searchHits = elasticsearchRestTemplate.search(searchQuery, ShopESEntity.class);// 6. 解析聚合結果并生成展示點
List<DisplayPointVO> displayPoints = new ArrayList<>();
if (searchHits.hasAggregations()) {List<? extends MultiBucketsAggregation.Bucket> buckets = getBuckets(searchHits.getAggregations(), "grid_agg");for (MultiBucketsAggregation.Bucket bucket : buckets) {List<MapUtils.Poi> poiList = getPoiList(bucket); // 獲取該 tile 內最多 100 個點MapUtils.Poi meanPoi = MapUtils.computeSphericalMean(poiList); // 計算球面幾何中位點if (meanPoi != null) {DisplayPointVO vo = new DisplayPointVO();vo.setCount(bucket.getDocCount());vo.setLat(meanPoi.getLat());vo.setLng(meanPoi.getLng());displayPoints.add(vo);}}
}
最終DSL如下:
{"size": 0,"aggs": {"poi_agg": {"geotile_grid": {"field": "latLng","precision": 29},"aggs": {"points": {"top_hits": {"size": 100,"_source": {"includes": ["latLng"]}}}}}},"query": {"geo_bounding_box": {"latLng": {"top_left": {"lat": 30.293813,"lon": 120.10432},"bottom_right": {"lat": 30.167403,"lon": 120.217002}}}}
}
球面幾何中位數算法(簡化版)
public static class MapUtils {public static class Poi {private final double lat;private final double lon;public Poi(double lat, double lon) {this.lat = lat;this.lon = lon;}public double getLat() { return lat; }public double getLng() { return lon; }}public static Poi computeSphericalMean(List<Poi> poiList) {if (poiList.isEmpty()) return null;double sumX = 0, sumY = 0, sumZ = 0;for (Poi p : poiList) {double latRad = Math.toRadians(p.lat);double lonRad = Math.toRadians(p.lon);sumX += Math.cos(latRad) * Math.cos(lonRad);sumY += Math.cos(latRad) * Math.sin(lonRad);sumZ += Math.sin(latRad);}int n = poiList.size();double avgX = sumX / n;double avgY = sumY / n;double avgZ = sumZ / n;double hyp = Math.sqrt(avgX * avgX + avgY * avgY);double latAvg = Math.toDegrees(Math.atan2(avgZ, hyp));double lngAvg = Math.toDegrees(Math.atan2(avgY, avgX));return new Poi(latAvg, lngAvg);}
}