谷歌地图渲染30W标记-性能优化

614 阅读13分钟

1.背景 Situation

公司业务希望通过地图标记在全球售卖自己货品门店,有两种不同类型门店,当地图缩小的时候自动汇总显示。

当缩小的时候显示就近汇总,并且不同门店区分颜色 如下所示 image.png

当放大的时候显示具体的门店标记 image.png

2.任务 Task

开发需求就是通过谷歌api,在地图上标记对应的店铺,并且当地图缩放变小的时候,就近聚合同类型节点。 技术实现点:

  1. 初始化地图
  2. 如何在地图标记
  3. 到一定缩放范围可以根据自定义类型进行合并并显示合并的数量

3.行动 Action

通过搜索 目前实现谷歌地图展示库有几种方案:

1. 使用vue的谷歌库

由于项目是内嵌在vue里面,心想使用vue的地图库更适合。 vue3-google-map.com

使用也很简单,只需要安装并传入几个基本参数即可。 image.png image.png

但是由于是基于标签做开发,对于有动态个人化交互并不是很方便。放弃

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绑定很多属性便于操作与判断。

image.png

image.png

遍历所有数据生成的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节点集合进行汇总的对象 参考

image.png

image.png

安装也很简单

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的库,其中发现有一个 image.png

image.png

image.png 发现有一个叫 js-markerclustererplus 还以为是现在用的 @googlemaps/markerclusterer的升级版本,通过 npm上面介绍才知道这段历史

image.png

  1. 最早是@googlemaps/markerclusterer
  2. 接着推出了@googlemaps/markerclustererplus
  3. 后面又把@googlemaps/markerclustererplus 改成为原来的@googlemaps/markerclusterer 只能放弃了

项目分析:

目前瓶颈主要是接口响应速度,渲染的数量太大

接口优化

  1. 由于接口返回了大量的店铺信息,但是在标记坐标只需要 经度+纬度+类型+唯一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
    },

渲染优化

  1. 渲染卡顿一般是因为大量的dom操作导致,加载和实例化30w的对象其实浏览器也还是扛的住,但是把30w标记直接渲染到浏览器就大问题了,所以初始化的时候,我们不添加到map(即不渲染到页面)
 const marker = new google.maps.marker.AdvancedMarkerElement({
      position: { lat: store.lat, lng: store.lng },
      map: null, // 这里 我们不绘制在谷歌地图上
      content: markerDiv
    })
  1. 渲染的范围太大,应该根据谷歌地图目前的可视区域渲染。比如当前如果只是某个街道,只把当前的街道数据添加到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)
  
      

效果

  1. 通过压缩请求的数据内容,确实把请求的时间从30秒降到了20秒
  2. 通过可视区域局部添加到map中,当可视区域不大的时候,还是可以接受。并且当拖拽由于可视范围发生变化,会重新移除原来已渲染,并重新渲染新的标记和汇总数据。

通过模拟数据,界面勉强能加载出来 image.png

image.png

但是当zoom缩小到国家级别的时候,一切又回到的解放前,由于原理还是需要标记完所有节点,再进行汇总所以,渲染的根本问题还是没有解决。并且频繁的操作节点绘制和释放,体验也是卡卡的,目测 3w个节点是勉强hold得住。但是离30w个节点还是挺遥远~~

参考代码:github.com/mjsong07/go… plan2 方案

方案3: 终极方案

好吧 我们再来一遍

1.背景 Situation

业务希望在全球地图里面,标记在全球售卖自己货品门店,有两种不同的类型门店,当地图缩小的时候自动汇总显示。并且可知的店铺达30w个(提供方使用ai爬虫,可能有脏数据)。

2.任务 Task

开发需求就是通过谷歌api,在地图上标记对应的店铺,并且当地图缩放变小的时候,就近聚合同类型节点。

3.行动 Action

分析

由于谷歌官方的方案是,怎么样都把所有标记都先实例化并标记好位置,在可视的区域通过MarkerClusterer一定的算法就近合并。

这个方案的硬伤就是把所有数据和运算渲染都交给了前端,比如在渲染国家的时候其实只需要不同类型的节点的一个汇总值,不需要所有的数据再做二次总。同理,在省份,城市,也可以按相同逻辑处理。

所以主要还是把计算和运算都交给后端,前端直接按需渲染即可。

思路

应该不同维度的时候做不同数据的汇总,比如说

  1. 当缩放到国家层级的时候,后台直接汇总分组国家返回给前端,前端直接汇总数据展示汇总标记
  2. 当缩放到省份级别,后台根据当前可视区域的范围,把省份汇总并返回前端做渲染
  3. 当缩放到市级别或跟更小,比如街道时候,可以让后端汇总有多少个节点,超过3000个则还是走城市的汇总,否则就把当前可视的所有节点返回,然后直接用谷歌的MarkerClusterer 汇总即可。

通过上面这种行政区域的划分,也更符合业务去分析数据。

代码实现
  1. 通过当前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())

  1. 通过map.getBounds()获取当前视图的可视坐标,并且转化为对象参数给后台

通过地图的两个经纬度坐标,就能告诉后台当前的可视区域

  • 南西
  • 北东

image.png

  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())
  1. 当地图第一次加载,地图移动执行 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缓存,算法优化等等)

国家维度

image.png

省维度

image.png

城市维度

image.png

街道维度

image.png

参考代码:github.com/mjsong07/go… plan3 方案

总结

  1. 其实最终方案跟我们平时做的列表分页的思想很类似,就是前端每次只展示需要显示的数据,所有汇总分页都交给后台运算。而不会把所有列表都数据都放在前端,并且自己计算分页。
  2. 由于拿来主义的想法,会一直引导你往一个不符合业务实际情况的技术方案走。在项目前期应该充分考虑实际数据量,业务的交互场景,并且跟后端全局规划讨论可行性,这样就少走很多弯路。

方案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个点 开始有一点卡 Aug-26-2025 17-31-04.gif

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节点的显示效果,比上一个方案丝滑了 Aug-26-2025 17-16-18.gif

参考

文档

在线例子

谷歌官方列子:googlemaps.github.io/js-samples/

优化