1.背景 Situation
公司业务希望通过地图标记在全球售卖自己货品门店,有两种不同类型门店,当地图缩小的时候自动汇总显示。
当缩小的时候显示就近汇总,并且不同门店区分颜色 如下所示
当放大的时候显示具体的门店标记
2.任务 Task
开发需求就是通过谷歌api,在地图上标记对应的店铺,并且当地图缩放变小的时候,就近聚合同类型节点。 技术实现点:
- 初始化地图
- 如何在地图标记
- 到一定缩放范围可以根据自定义类型进行合并并显示合并的数量
3.行动 Action
通过搜索 目前实现谷歌地图展示库有几种方案:
1. 使用vue的谷歌库
由于项目是内嵌在vue里面,心想使用vue的地图库更适合。 vue3-google-map.com
使用也很简单,只需要安装并传入几个基本参数即可。
但是由于是基于标签做开发,对于有动态个人化交互并不是很方便。放弃
2. 使用官方的api
由于公司有类似的项目实现,本着拿来主义的精神(copy that)。基本实现也是直接用谷歌官方的 官方文档
2.1 初始化地图
安装库
npm i @googlemaps/js-api-loader
初始化
<template>
<div class="map-container">
<div ref="mapContainer" class="map"></div>
</div>
</template>
<script lang="ts" setup>
import { Loader } from '@googlemaps/js-api-loader'
const mapContainer = ref(null)
const apiKey = 'xxxx'
let map: google.maps.Map
const initMap = async () => {
try {
const loader = new Loader({
apiKey:apiKey , // 这里改成自己的key
version: 'weekly',
libraries: ['places']
})
await loader.load()
const { Map } = await google.maps.importLibrary('maps')
map = new Map(mapContainer.value, {
center: { lat: 54.0, lng: -2.0 },
zoom: 10,
mapId: 'DEMO_MAP_ID',
mapTypeId: 'roadmap',
mapTypeControl: false,
gestureHandling: 'greedy',
streetViewControl: false
})
} catch (error) {
console.error('Error loading Google Maps:', error)
}
</script>
2.2 AdvancedMarkerElement绘制标记 参考
这里推荐使用content传入自定义的html 方式,后续可以在html绑定很多属性便于操作与判断。
遍历所有数据生成的AdvancedMarkerElement节点放入一个markers数组
// 自定义html
const createMarkerDivContent = (id: string, type: 'normal' | 'active' = 'normal') => {
let url = ''
if (type === 'normal') {
url = '/public/dashboard/local2.png'
} else {
url = '/public/dashboard/local.png'
}
const customContent = document.createElement('div')
customContent.innerHTML = `
<img class="marker-hg-img" style="width:30px" data-id="${id}" src="${url}" alt="Marker Icon" >
`
return customContent
}
let markers: google.maps.marker.AdvancedMarkerElement[] = []
//便利 所有节点 设置到上面全局变量map里面
const initMarkerAndClusters = (list: any[]) => {
// 创建所有标记点
markers = list.map((store) => {
const markerDiv = createMarkerDivContent(store.id, 'normal')
const marker = new google.maps.marker.AdvancedMarkerElement({
position: { lat: store.lat, lng: store.lng },
map: map,
title: `${store.type}`,
content: markerDiv,
})
// 添加点击事件
marker.addListener('click', async () => {
// ...
})
return marker
})
}
2.3 MarkerClusterer汇总对象
就是可以把上面的AdvancedMarkerElement节点集合进行汇总的对象 参考
安装也很简单
npm install @googlemaps/markerclusterer
在遍历生成的AdvancedMarkerElement节点时候同时传入红色markersRed 和绿色的 markersGreen 集合里,最后通过new MarkerClusterer({ map, markers: markersRed }) 生成聚合
import { MarkerClusterer } from '@googlemaps/markerclusterer'
// 定义两个MarkerClusterer 对象
let markerClusters: {
red: MarkerClusterer | null
green: MarkerClusterer | null
} = {
red: null,
green: null
}
const markersRed: google.maps.marker.AdvancedMarkerElement[] = []
const markersGreen: google.maps.marker.AdvancedMarkerElement[] = []
// 完善initMarkerAndClusters方法
const initMarkerAndClusters = (list: any[]) => {
// 创建所有标记点
markers = list.map((store) => {
const markerDiv = createMarkerDivContent(store.id, 'normal')
const marker = new google.maps.marker.AdvancedMarkerElement({
position: { lat: store.lat, lng: store.lng },
map: map,
title: `${store.type}`,
content: markerDiv,
})
// 添加点击事件
marker.addListener('click', async () => {
})
if (store.type) {
markersGreen.push(marker)
} else {
markersRed.push(marker)
}
return marker
})
// 创建红色聚类器
markerClusters.red = new MarkerClusterer({
map,
markers: markersRed,
algorithmOptions: algorithmOptions,
renderer: {
render: ({ count, position }) => {
return new google.maps.marker.AdvancedMarkerElement({
position,
content: createClusterIcon(count, 'red')
})
}
}
})
// 创建绿色聚类器
markerClusters.green = new MarkerClusterer({
map,
markers: markersGreen,
algorithmOptions: algorithmOptions,
renderer: {
render: ({ count, position }) => {
return new google.maps.marker.AdvancedMarkerElement({
position,
content: createClusterIcon(count, 'green')
})
}
}
})
}
// 通过renderer 定义渲染聚类器样式
const createClusterIcon = (count: number, type: string) => {
const color = type === 'green' ? '#00BD24' : '#E0312C'
const svg = `
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.280000">
<circle id="椭圆 201" cx="20" cy="20" r="20" fill="${color}" />
</g>
<g opacity="0.520000">
<circle id="椭圆 202" cx="20" cy="20" r="17" fill="${color}" />
</g>
<circle id="椭圆 203" cx="20" cy="20" r="14" fill="${color}" />
<text x="20" y="25" font-size="10" fill="#fff" text-anchor="middle" dominant-baseline="top">
${count}
</text>
</svg>
`
const parser = new DOMParser()
const pinSvg = parser.parseFromString(svg, 'image/svg+xml').documentElement
return pinSvg
}
4.结果 Result
在本地调试貌似稳的一B,结果快到提测阶段,居然来了30w个节点标记正式数据,页面不再淡定了。
- 接口请求已经耗时30秒
- 然后渲染又卡个5秒
- 当界面缩放到一定zoom视角控制台直接报错了,已经超出MarkerClusterer的调用栈
renderer.ts:45
Uncaught RangeError: Maximum call stack size exceeded at new ClusterStats (renderer.ts:45:19)
at MarkerClusterer.renderClusters (markerclusterer.ts:249:19)
at MarkerClusterer.render (markerclusterer.ts:213:14)
at _.il (main.js:156:464) at b (map.js:37:476)
参考代码:github.com/mjsong07/go… plan1 方案
方案2:临时补救
上网找找方案
在 github.com/googlemaps 我们看到很多js的库,其中发现有一个
发现有一个叫 js-markerclustererplus 还以为是现在用的 @googlemaps/markerclusterer的升级版本,通过 npm上面介绍才知道这段历史
- 最早是@googlemaps/markerclusterer
- 接着推出了@googlemaps/markerclustererplus
- 后面又把@googlemaps/markerclustererplus 改成为原来的@googlemaps/markerclusterer 只能放弃了
项目分析:
目前瓶颈主要是接口响应速度,渲染的数量太大
接口优化
- 由于接口返回了大量的店铺信息,但是在标记坐标只需要 经度+纬度+类型+唯一id即可,所以优化的接口请求的数据结构,并且把每个参数尽量用一个字母代替。
原接口
{
"id": 1,
"storeName": "xxx",
"storeNo": "xxx",
"city": "xxx",
"address": "xxx",
"countryCode": "xxx",
"countryName": "xxx",
"storeType": "xxxx",
"postalCode": "xxx",
"lat": 55.762566,
"lng": 37.613171,
"type": 1
},
优化后
{
"i": 1,
"a": 55.762566,
"n": 37.613171,
"t": 1
},
渲染优化
- 渲染卡顿一般是因为大量的dom操作导致,加载和实例化30w的对象其实浏览器也还是扛的住,但是把30w标记直接渲染到浏览器就大问题了,所以初始化的时候,我们不添加到map(即不渲染到页面)
const marker = new google.maps.marker.AdvancedMarkerElement({
position: { lat: store.lat, lng: store.lng },
map: null, // 这里 我们不绘制在谷歌地图上
content: markerDiv
})
- 渲染的范围太大,应该根据谷歌地图目前的可视区域渲染。比如当前如果只是某个街道,只把当前的街道数据添加到map,并且移除之前添加到map的数据。再进行聚合。
利用idle事件 统一处理不同情况做不同的节点处理(注意zoom值越小视角越小 街道->国家)
- 当zoom发生变化并且zoom是是从7放变小的时候,由于显示视角缩小,所以走全局刷新逻辑,临界点只需要加载一次,后面再放大也不触发。
- 当zoom没有变化,并且大于等于7,由于显示视角放大,根据当前可视区域局部刷新
map.addListener('idle', (event:any) => {
const currentCenter = map.getCenter()
const currentZoom = map.getZoom()
const isZoom = lastZoom.value !== currentZoom
if (isZoom) {
console.log('地图缩放:上一次', lastZoom.value, '当前', currentZoom)
if (currentZoom < 7 && lastZoom.value >= 7) {
nextTick(() => {
resetAllMarkerClusters() // 全局刷新
})
}
} else {
console.log('地图移动:上一次', JSON.stringify(lastCenter.value), '当前', JSON.stringify(currentCenter))
if (map.getZoom() >= 7) {
resetPartialMarkerClusters() // 局部刷新
}
}
lastZoom.value = map.getZoom()
lastCenter.value = map.getCenter()
lastBounds.value = map.getBounds()
})
//全局刷新
const resetAllMarkerClusters = debounce(() => {
console.log('全局刷新 start')
//还原显示所有标记
markerClusters.red && markerClusters.red.clearMarkers()
markerClusters.green && markerClusters.green.clearMarkers()
let newRedMarkers: google.maps.marker.AdvancedMarkerElement[] = []
let newGreenMarkers: google.maps.marker.AdvancedMarkerElement[] = []
markers.forEach((marker) => {
if (marker.title === '0') {
newRedMarkers.push(marker)
} else {
newGreenMarkers.push(marker)
}
marker.map = map
})
if (markerClusters.red) {
markerClusters.red!.addMarkers(newRedMarkers)
}
if (markerClusters.green) {
markerClusters.green!.addMarkers(newGreenMarkers)
}
// loading.value = false
console.log('全局刷新 end')
}, 1000)
//局部刷新
const resetPartialMarkerClusters = debounce(() => {
console.log('局部刷新 start')
markerClusters.red && markerClusters.red.clearMarkers()
markerClusters.green && markerClusters.green.clearMarkers()
let newRedMarkers: google.maps.marker.AdvancedMarkerElement[] = []
let newGreenMarkers: google.maps.marker.AdvancedMarkerElement[] = []
markers.forEach((marker) => {
if (map.getBounds()?.contains(marker.position!)) {
if (marker.title === '0') {
newRedMarkers.push(marker)
} else {
newGreenMarkers.push(marker)
}
marker.map = map
} else {
marker.map = null
}
})
if (markerClusters.red) {
markerClusters.red!.addMarkers(newRedMarkers)
}
if (markerClusters.green) {
markerClusters.green!.addMarkers(newGreenMarkers)
}
console.log('局部刷新 end')
}, 1000)
效果
- 通过压缩请求的数据内容,确实把请求的时间从30秒降到了20秒
- 通过可视区域局部添加到map中,当可视区域不大的时候,还是可以接受。并且当拖拽由于可视范围发生变化,会重新移除原来已渲染,并重新渲染新的标记和汇总数据。
通过模拟数据,界面勉强能加载出来
但是当zoom缩小到国家级别的时候,一切又回到的解放前,由于原理还是需要标记完所有节点,再进行汇总所以,渲染的根本问题还是没有解决。并且频繁的操作节点绘制和释放,体验也是卡卡的,目测 3w个节点是勉强hold得住。但是离30w个节点还是挺遥远~~
参考代码:github.com/mjsong07/go… plan2 方案
方案3: 终极方案
好吧 我们再来一遍
1.背景 Situation
业务希望在全球地图里面,标记在全球售卖自己货品门店,有两种不同的类型门店,当地图缩小的时候自动汇总显示。并且可知的店铺达30w个(提供方使用ai爬虫,可能有脏数据)。
2.任务 Task
开发需求就是通过谷歌api,在地图上标记对应的店铺,并且当地图缩放变小的时候,就近聚合同类型节点。
3.行动 Action
分析
由于谷歌官方的方案是,怎么样都把所有标记都先实例化并标记好位置,在可视的区域通过MarkerClusterer一定的算法就近合并。
这个方案的硬伤就是把所有数据和运算渲染都交给了前端,比如在渲染国家的时候其实只需要不同类型的节点的一个汇总值,不需要所有的数据再做二次总。同理,在省份,城市,也可以按相同逻辑处理。
所以主要还是把计算和运算都交给后端,前端直接按需渲染即可。
思路
应该不同维度的时候做不同数据的汇总,比如说
- 当缩放到国家层级的时候,后台直接汇总分组国家返回给前端,前端直接汇总数据展示汇总标记
- 当缩放到省份级别,后台根据当前可视区域的范围,把省份汇总并返回前端做渲染
- 当缩放到市级别或跟更小,比如街道时候,可以让后端汇总有多少个节点,超过3000个则还是走城市的汇总,否则就把当前可视的所有节点返回,然后直接用谷歌的MarkerClusterer 汇总即可。
通过上面这种行政区域的划分,也更符合业务去分析数据。
代码实现
- 通过当前zoom 判断当时是国家/省/市/街道,然后告诉后端level是要汇总什么数据
const countryInitZoom = 5 // 国家
const provinceZoom = 8 // 省/州 临界点
const cityZoom = 12 // 城市
function getZoomLevelType(zoom: number) {
// 定义经验范围(可根据实际情况调整)
if (zoom! <= countryInitZoom) {
return 'country'
} else if (zoom! > countryInitZoom && zoom! <= provinceZoom) {
return 'province'
} else {
return 'city'
}
}
const level = getZoomLevelType(map.getZoom())
- 通过map.getBounds()获取当前视图的可视坐标,并且转化为对象参数给后台
通过地图的两个经纬度坐标,就能告诉后台当前的可视区域
- 南西
- 北东
const getBoundObj: any = (bounds: google.maps.LatLngBounds) => {
const boundObj = {
southWest: {
lat: bounds.getSouthWest().lat(),
lng: bounds.getSouthWest().lng()
},
northEast: {
lat: bounds.getNorthEast().lat(),
lng: bounds.getNorthEast().lng()
}
}
return boundObj
}
const boundObj = getBoundObj(map.getBounds())
- 当地图第一次加载,地图移动执行 resetData方法 刷新数据
map.addListener('idle', async () => {
if (isFristLoad.value) {
isFristLoad.value = false
resetData()
return
}
})
map.addListener('dragend', () => {
resetData()
})
4.刷新数据方法:判断当前zoom是否为城市维度,并且汇总数量是否小于3000,小于则直接显示谷歌聚合逻辑,大于则正常走 国家/省/城市汇总。
import { debounce } from 'lodash-es'
const googleMaxCnt = 3000 // 谷歌最大显示数量
const resetData = debounce(async () => {
if (!map || !map.getBounds()) {
return
}
const zoom = map.getZoom()!
if (zoom <= 1) {
cleanAllMarkers()
return
}
let isLoadGoogleClusters = false
if (zoom >= cityZoom) {
// 城市维度的时候,
//先获取当前总数,如果总数大于3000,就不显示谷歌自带聚合
const resCnt = await getStoreCount(getNowMapBounds())
if (resCnt && resCnt.data && resCnt.data.storeCount <= googleMaxCnt) {
isLoadGoogleClusters = true
}
}
const level = getZoomLevelType(zoom!)
let requestBound = getNowMapBounds()
if (level === 'country') {
requestBound = null
}
if (isLoadGoogleClusters) { //走谷歌汇总
reloadRightGoogList()
} else { //汇总加载
let res = await getLatLngStatistics(requestBound, getZoomLevelType(zoom!)) // 后台汇总
cleanAllMarkers() //清空上一次的数据内容
createCountrySummy(res.data, level) // 自定义汇总的效果
}
}, 1000)
谷歌聚合数据
const reloadRightGoogList = () => {
rightMapLoading.value = true
getLatLngList(getNowMapBounds()) // 获取谷歌当前区域的所有接口数据
.then((res: any) => {
cleanAllMarkers()
initMarkers(res.data)
initClusters(res.data)
})
.finally(() => {
rightMapLoading.value = false
})
}
//初始化聚合器
const initClusters = (list: MapLatLng[]) => {
markersRed = []
markersGreen = []
// // 创建红色门店聚类器
markerClusters.red = new MarkerClusterer({
map,
markers: markersRed,
algorithmOptions: algorithmOptions,
renderer: {
render: ({ count, position }) => {
return new google.maps.marker.AdvancedMarkerElement({
position,
content: createClusterIcon(count, 'red')
})
}
}
})
// 创建绿色门店聚类器
markerClusters.green = new MarkerClusterer({
map,
markers: markersGreen,
algorithmOptions: algorithmOptions,
renderer: {
render: ({ count, position }) => {
return new google.maps.marker.AdvancedMarkerElement({
position,
content: createClusterIcon(count, 'green')
})
}
}
})
}
const cleanAllMarkers = () => {
markersRed = []
markersGreen = []
markers.forEach((marker) => {
marker.map = null
})
markers = []
markerClusters.red && markerClusters.red.clearMarkers()
markerClusters.green && markerClusters.green.clearMarkers()
markerClusters.red = null
markerClusters.green = null
countrySummyList &&
countrySummyList.forEach((marker) => {
marker.map = null
})
countrySummyList = []
}
根据汇总 显示自定义汇总数据
const createCountrySummy = (countryList: any[], level: string) => {
cleanAllMarkers()
const zoom = map.getZoom() // 获取当前缩放级别
let size = 40 //默认40
if (level === 'country') {
size = calculateClusterSize(zoom!) // 动态计算尺寸
}
countryList.forEach((obj) => {
let initLat = obj.lat
let initLng = obj.lng
let title = obj.name
if (obj.level === 'country' && obj.name && countryLatLngMap[obj.name]) {
let initLatLng = null
if (obj.type === 1) {
initLatLng = countryLatLngMap[obj.name].type
} else {
initLatLng = countryLatLngMap[obj.name].no_type
}
if (initLatLng) {
initLat = initLatLng.lat
initLng = initLatLng.lng
}
}
const marker = new google.maps.marker.AdvancedMarkerElement({
position: { lat: initLat, lng: initLng },
map: map,
title: title,
content: createClusterIcon(obj.total, obj.type === 1 ? 'green' : 'red', size)
})
marker.addListener('click', async () => {
if (zoom! + 2 <= detailZoom) {
map.setZoom(zoom! + 1)
map.panTo(new google.maps.LatLng(marker.position.lat, marker.position.lng))
}
})
countrySummyList.push(marker)
})
}
const calculateClusterSize = (zoom: number): number => {
// 缩放级别越大,图标越小(根据实际需求调整公式) 最小 40 最大 70
return Math.max(40, 90 - (1 / (zoom - 2)) * 70) // 最小10px,每级减少2px 6 5 4 3 2 1
}
const createClusterIcon = (count: number, type: string, size: number = 40) => {
const color = type === 'green' ? '#00BD24' : '#E0312C'
const svg = `
<svg width="${size}" height="${size}" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.280000">
<circle id="椭圆 201" cx="20" cy="20" r="20" fill="${color}" />
</g>
<g opacity="0.520000">
<circle id="椭圆 202" cx="20" cy="20" r="17" fill="${color}" />
</g>
<circle id="椭圆 203" cx="20" cy="20" r="14" fill="${color}" />
<text x="20" y="25" font-size="10" fill="#fff" text-anchor="middle" dominant-baseline="top">
${count}
</text>
</svg>
`
const parser = new DOMParser()
return parser.parseFromString(svg, 'image/svg+xml').documentElement
}
4.结果 Result
由于所有运算都放在服务端,前端只做少量渲染,所以加速度和性能都有了飞一样的提升,当然仍然有很多可优化的空间… (前端的缓存优化,渲染优化,后端的查询优化,redis缓存,算法优化等等)
国家维度
省维度
城市维度
街道维度
参考代码:github.com/mjsong07/go… plan3 方案
总结
- 其实最终方案跟我们平时做的列表分页的思想很类似,就是前端每次只展示需要显示的数据,所有汇总分页都交给后台运算。而不会把所有列表都数据都放在前端,并且自己计算分页。
- 由于拿来主义的想法,会一直引导你往一个不符合业务实际情况的技术方案走。在项目前期应该充分考虑实际数据量,业务的交互场景,并且跟后端全局规划讨论可行性,这样就少走很多弯路。
方案4: 纯前端canvas方案
感谢aou同学留言,还有一种方案是在一次性通过canvas在一个画布渲染所有节点,就省去了大量初始化dom的性能瓶颈问题(这里暂时忽略大数据了传输问题,可以考虑用增量数据同步)
- 方法1:继承google.maps.OverlayView 进行canvas绘制
- 方法2:继承deck.GoogleMapsOverlay 绘制 进行绘制
google.maps.OverlayView 版本
<!DOCTYPE html>
<html>
<head>
<title>OverlayView with 300k marker-like points</title>
<style>
#map {
width: 100%;
height: 100vh;
}
canvas.overlay {
position: absolute;
}
</style>
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
</head>
<body>
<div id="map"></div>
<script>
function initMap() {
const map = new google.maps.Map(document.getElementById("map"), {
center: { lat: 37.7749, lng: -122.4194 },
zoom: 10,
});
class CanvasOverlay extends google.maps.OverlayView {
constructor() {
super();
// 一次性生成 30 万固定点
this.fixedPoints = [];
for (let i = 0; i < 300000; i++) {
const lat = 37.7749 + (Math.random() - 0.5) * 1.0;
const lng = -122.4194 + (Math.random() - 0.5) * 1.0;
this.fixedPoints.push({ lat, lng });
}
}
onAdd() {
this.canvas = document.createElement("canvas");
this.canvas.className = "overlay";
this.ctx = this.canvas.getContext("2d");
this.getPanes().overlayLayer.appendChild(this.canvas);
}
draw() {
const projection = this.getProjection();
const bounds = this.getMap().getBounds();
if (!bounds || !projection) return;
const sw = projection.fromLatLngToDivPixel(bounds.getSouthWest());
const ne = projection.fromLatLngToDivPixel(bounds.getNorthEast());
const width = ne.x - sw.x;
const height = sw.y - ne.y;
this.canvas.style.left = sw.x + "px";
this.canvas.style.top = ne.y + "px";
this.canvas.width = width;
this.canvas.height = height;
const ctx = this.ctx;
ctx.clearRect(0, 0, width, height);
// 固定样式
ctx.fillStyle = "rgba(255, 0, 0, 0.8)";
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1;
// 使用固定的点来绘制,而不是每次随机!
for (const point of this.fixedPoints) {
const pixel = projection.fromLatLngToDivPixel(
new google.maps.LatLng(point.lat, point.lng)
);
if (!pixel) continue; // 安全判断
const x = pixel.x - sw.x;
const y = pixel.y - ne.y;
ctx.beginPath();
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
onRemove() {
if (this.canvas && this.canvas.parentNode) {
this.canvas.parentNode.removeChild(this.canvas);
}
}
}
const overlay = new CanvasOverlay();
overlay.setMap(map);
}
initMap();
</script>
</body>
</html>
使用google.maps.OverlayView绘制30w个点 开始有一点卡
deck.GoogleMapsOverlay
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>deck.gl + Google Maps with Marker-like Points</title>
<style>
#map {
width: 100%;
height: 100vh;
}
</style>
<!-- Google Maps -->
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
<!-- deck.gl + Google Maps integration -->
<script src="https://unpkg.com/deck.gl@8.9.27/dist.min.js"></script>
<script src="https://unpkg.com/@deck.gl/google-maps@8.9.27/dist.min.js"></script>
</head>
<body>
<div id="map"></div>
<script>
function initMap() {
const map = new google.maps.Map(document.getElementById("map"), {
center: { lat: 37.7749, lng: -122.4194 },
zoom: 10,
});
// 随机生成 300k 个点
const data = Array.from({ length: 300000 }, (_, i) => ({
id: i,
name: "节点 " + i,
position: [
-122.4194 + (Math.random() - 0.5) * 1.5, // lng
37.7749 + (Math.random() - 0.5) * 1.5, // lat
],
}));
// Google Maps InfoWindow
const infoWindow = new google.maps.InfoWindow();
// 自定义节点样式(类似 Google 红色 Marker:外圈白色,内圈红色)
const scatterplot = new deck.ScatterplotLayer({
id: "scatterplot-layer",
data,
pickable: true,
getPosition: d => d.position,
getFillColor: [219, 68, 55, 255], // Google 红
getRadius: 80, // 半径 (米)
radiusUnits: "meters",
stroked: true,
lineWidthMinPixels: 2,
getLineColor: [255, 255, 255, 255], // 白色边框
onClick: info => {
if (info.object) {
const [lng, lat] = info.object.position;
infoWindow.setPosition({ lat, lng });
infoWindow.setContent(
`<div><b>${info.object.name}</b><br/>坐标: ${lat.toFixed(
4
)}, ${lng.toFixed(4)}</div>`
);
infoWindow.open(map);
}
},
});
// 加载到 Google Maps
const overlay = new deck.GoogleMapsOverlay({
layers: [scatterplot],
});
overlay.setMap(map);
}
initMap();
</script>
</body>
</html>
deck.GoogleMapsOverlay 渲染30w节点的显示效果,比上一个方案丝滑了
参考
文档
- 谷歌地图聚合文档 developers.google.com/maps/docume…
- 谷歌地图接口定义 googlemaps.github.io/js-adv-mark…
- vue3 map 地图文档 vue3-google-map.com/advanced-us…
- npm 地图文档 www.npmjs.com/package/@go…
- 地图为区域json 下载 geojson-maps.kyd.au/
在线例子
谷歌官方列子:googlemaps.github.io/js-samples/
优化
- 前端地图 优化方案1 www.option40.com/blog/superc…
- 前端地图 优化方案2 stackoverflow.com/questions/3…
- 后台redis 优化查询方案 pfisterer.dev/posts/cache… www.option40.com/blog/superc…