返回笔记首页

12.1 地图集成方案

主题配置

简历描述模板

负责公司地图可视化系统的技术选型与架构设计,对比评估了高德、百度、Mapbox等主流地图方案,最终采用高德地图+Mapbox混合方案。封装了统一的地图组件库,支持多种地图引擎的无缝切换。通过海量点聚合、瓦片分级加载等优化手段,实现了10万+点标记的流畅渲染。开发了自定义地图样式编辑器,支持企业级的地图主题定制。项目上线后服务于公司5个产品线,地图加载速度提升60%,标记点击响应时间控制在50ms以内。

SOP 标准回答

面试官:你们项目为什么选择这个地图方案?地图集成遇到过什么技术难点?

我来说说我们地图技术选型和集成的经验。

首先说选型。我们当时对比了高德、百度和Mapbox这三个主流方案。高德和百度是国内主流,数据更新快,国内地址覆盖完整,API也成熟。Mapbox是国际化方案,样式定制能力强,性能也不错。

我做了详细的对比测试。功能层面,高德和百度的POI数据更丰富,路径规划、地理编码这些服务很完善。Mapbox的样式定制更灵活,可以通过Mapbox Studio做深度定制。性能方面,在10万个点标记的场景下,Mapbox的渲染速度最快,但高德的聚合插件做得很好。成本上,高德和百度按调用量计费,Mapbox有免费额度但超出后也不便宜。

最后我们采用了混合方案。业务地图用高德,因为路径规划、POI搜索这些功能成熟。数据可视化用Mapbox,因为我们需要很酷炫的样式效果。然后我封装了一个统一的地图组件,抹平了两个地图引擎的API差异,业务层面用起来是一样的。

集成过程中遇到的最大难点是海量点标记的性能问题。我们有个车辆监控的场景,需要同时展示几万辆车的位置。如果直接加marker,页面会卡死。我做了几个优化:一是点聚合,把相近的点合并显示。二是视口裁剪,只渲染可见区域的点。三是分级加载,根据缩放等级动态调整点的密度。这些优化做完后,10万个点也能流畅运行。

另一个难点是坐标系转换。高德用的是GCJ-02,GPS用的是WGS-84,百度又是BD-09,各不一样。我封装了一个坐标转换工具,可以自动识别坐标系并转换。还做了坐标纠偏,因为国内地图都有偏移,GPS坐标直接用会不准。

还有就是地图样式定制。客户要求地图主题要跟他们的系统风格一致,比如暗色主题、科技蓝主题。我基于Mapbox的样式规范,开发了一个可视化配置工具,可以实时预览效果,导出JSON配置。这个工具后来在公司内部推广,其他项目也在用。

整体下来,地图系统稳定运行了一年多,没出过大的问题。性能、稳定性、可维护性都不错。

难点与亮点分析

难点1:多地图引擎的统一封装

问题:项目需要支持高德、百度、Mapbox三种地图,但API完全不同,如何统一接口避免业务代码改动?

解决方案:设计了适配器模式。定义统一的地图接口(MapAdapter),包括创建地图、添加标记、绘制路线等核心方法。为每个地图引擎实现对应的适配器(AMapAdapter、BMapAdapter、MapboxAdapter),内部调用各自的API。业务层只依赖MapAdapter接口,切换地图引擎只需要改变适配器实例。还实现了懒加载,按需加载地图SDK,减小bundle体积。

效果:地图切换只需修改一行配置,业务代码零改动。支持在同一个页面中同时使用多个地图引擎。

难点2:10万+点标记的性能优化

问题:车辆监控场景需要同时显示5万+车辆位置,直接渲染导致页面卡死,浏览器崩溃。

解决方案:多层优化策略。一是点聚合算法,使用MarkerClusterer将相近的点合并,显示为数字标记,点击展开。二是视口裁剪,只渲染地图可视区域内的点,视口外的点不加载。三是虚拟化渲染,预先计算点的位置,使用Canvas绘制代替DOM标记。四是分级加载,根据缩放等级显示不同密度的数据,缩小时只显示重要点。五是Web Worker后台计算,点的聚合和过滤逻辑放到Worker中,避免阻塞主线程。

效果:10万个点的初始加载时间从30秒降到3秒,交互流畅度从卡顿到60fps,内存占用降低70%。

难点3:坐标系转换的精度问题

问题:GPS设备返回WGS-84坐标,但高德地图用GCJ-02,直接显示会有几百米偏差,导致定位不准。

解决方案:实现了高精度坐标转换算法。内置WGS-84、GCJ-02、BD-09三种坐标系的互转公式,参考国测局的加密算法。对于边界区域(海外、港澳台),自动识别并跳过纠偏。还做了坐标缓存,相同坐标只转换一次。提供自动检测功能,输入坐标自动判断坐标系类型。

效果:坐标转换误差控制在2米以内,转换速度10万坐标/秒,完全满足实时定位需求。

技术亮点

  1. 地图引擎热插拔:支持运行时动态切换地图引擎,无需刷新页面。
  2. 智能降级策略:地图加载失败时自动降级到备用方案,保证服务可用。
  3. 样式主题系统:支持十几种预设主题,可视化编辑器实现所见即所得。
  4. 离线地图支持:关键区域的地图瓦片预缓存,离线也能使用。

完整技术实现

1. 地图适配器基类

MapAdapter.js - 统一地图接口

javascript
// adapters/MapAdapter.js
export class MapAdapter {
  constructor(container, options = {}) {
    this.container = container
    this.options = options
    this.map = null
    this.markers = []
  }

  // 初始化地图
  async init() {
    throw new Error('Method not implemented')
  }

  // 设置中心点
  setCenter(lng, lat, zoom) {
    throw new Error('Method not implemented')
  }

  // 添加标记
  addMarker(lng, lat, options) {
    throw new Error('Method not implemented')
  }

  // 移除标记
  removeMarker(marker) {
    throw new Error('Method not implemented')
  }

  // 清空所有标记
  clearMarkers() {
    throw new Error('Method not implemented')
  }

  // 绘制路线
  drawRoute(path, options) {
    throw new Error('Method not implemented')
  }

  // 销毁地图
  destroy() {
    throw new Error('Method not implemented')
  }
}

2. 高德地图适配器

AMapAdapter.js - 高德地图实现

javascript
// adapters/AMapAdapter.js
import { MapAdapter } from './MapAdapter'

export class AMapAdapter extends MapAdapter {
  async init() {
    return new Promise((resolve, reject) => {
      if (window.AMap) {
        this.createMap()
        resolve()
        return
      }

      // 动态加载高德地图SDK
      const script = document.createElement('script')
      script.src = `https://webapi.amap.com/maps?v=2.0&key=YOUR_AMAP_KEY`
      script.onload = () => {
        this.createMap()
        resolve()
      }
      script.onerror = reject
      document.head.appendChild(script)
    })
  }

  createMap() {
    this.map = new AMap.Map(this.container, {
      center: [this.options.lng || 116.397428, this.options.lat || 39.90923],
      zoom: this.options.zoom || 11,
      mapStyle: this.options.style || 'amap://styles/dark'
    })
  }

  setCenter(lng, lat, zoom) {
    this.map.setCenter([lng, lat])
    if (zoom !== undefined) {
      this.map.setZoom(zoom)
    }
  }

  addMarker(lng, lat, options = {}) {
    const marker = new AMap.Marker({
      position: [lng, lat],
      title: options.title,
      icon: options.icon,
      offset: new AMap.Pixel(-13, -30)
    })

    if (options.onClick) {
      marker.on('click', () => options.onClick(marker))
    }

    marker.setMap(this.map)
    this.markers.push(marker)
    return marker
  }

  removeMarker(marker) {
    marker.setMap(null)
    const index = this.markers.indexOf(marker)
    if (index > -1) {
      this.markers.splice(index, 1)
    }
  }

  clearMarkers() {
    this.markers.forEach(marker => marker.setMap(null))
    this.markers = []
  }

  drawRoute(path, options = {}) {
    const polyline = new AMap.Polyline({
      path: path,
      strokeColor: options.color || '#3366FF',
      strokeWeight: options.width || 5,
      strokeOpacity: options.opacity || 0.8
    })

    polyline.setMap(this.map)
    return polyline
  }

  destroy() {
    if (this.map) {
      this.clearMarkers()
      this.map.destroy()
      this.map = null
    }
  }
}

3. Mapbox适配器

MapboxAdapter.js - Mapbox实现

javascript
// adapters/MapboxAdapter.js
import { MapAdapter } from './MapAdapter'

export class MapboxAdapter extends MapAdapter {
  async init() {
    return new Promise((resolve, reject) => {
      if (window.mapboxgl) {
        this.createMap()
        resolve()
        return
      }

      // 加载Mapbox SDK
      const link = document.createElement('link')
      link.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'
      link.rel = 'stylesheet'
      document.head.appendChild(link)

      const script = document.createElement('script')
      script.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
      script.onload = () => {
        this.createMap()
        resolve()
      }
      script.onerror = reject
      document.head.appendChild(script)
    })
  }

  createMap() {
    mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN'

    this.map = new mapboxgl.Map({
      container: this.container,
      style: this.options.style || 'mapbox://styles/mapbox/dark-v10',
      center: [this.options.lng || 116.397428, this.options.lat || 39.90923],
      zoom: this.options.zoom || 11
    })

    this.map.on('load', () => {
      this.mapLoaded = true
    })
  }

  setCenter(lng, lat, zoom) {
    this.map.setCenter([lng, lat])
    if (zoom !== undefined) {
      this.map.setZoom(zoom)
    }
  }

  addMarker(lng, lat, options = {}) {
    const el = document.createElement('div')
    el.className = 'mapbox-marker'
    el.style.width = '30px'
    el.style.height = '30px'
    el.style.backgroundImage = `url(${options.icon || '/marker.png'})`
    el.style.backgroundSize = 'cover'

    const marker = new mapboxgl.Marker(el)
      .setLngLat([lng, lat])
      .addTo(this.map)

    if (options.onClick) {
      el.addEventListener('click', () => options.onClick(marker))
    }

    this.markers.push(marker)
    return marker
  }

  removeMarker(marker) {
    marker.remove()
    const index = this.markers.indexOf(marker)
    if (index > -1) {
      this.markers.splice(index, 1)
    }
  }

  clearMarkers() {
    this.markers.forEach(marker => marker.remove())
    this.markers = []
  }

  drawRoute(path, options = {}) {
    const geojson = {
      type: 'Feature',
      geometry: {
        type: 'LineString',
        coordinates: path
      }
    }

    const layerId = 'route-' + Date.now()

    this.map.addSource(layerId, {
      type: 'geojson',
      data: geojson
    })

    this.map.addLayer({
      id: layerId,
      type: 'line',
      source: layerId,
      layout: {
        'line-join': 'round',
        'line-cap': 'round'
      },
      paint: {
        'line-color': options.color || '#3366FF',
        'line-width': options.width || 5,
        'line-opacity': options.opacity || 0.8
      }
    })

    return layerId
  }

  destroy() {
    if (this.map) {
      this.clearMarkers()
      this.map.remove()
      this.map = null
    }
  }
}

4. 统一地图组件

UnifiedMap.vue - 可切换地图引擎的组件

vue
<template>
  <div class="unified-map">
    <div class="map-controls">
      <select v-model="mapType" @change="switchMap">
        <option value="amap">高德地图</option>
        <option value="mapbox">Mapbox</option>
      </select>
      <button @click="addRandomMarkers">添加标记</button>
      <button @click="clearMarkers">清空标记</button>
    </div>
    <div ref="mapContainer" class="map-container"></div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { AMapAdapter } from './adapters/AMapAdapter'
import { MapboxAdapter } from './adapters/MapboxAdapter'

const mapContainer = ref(null)
const mapType = ref('amap')

let mapAdapter = null

const initMap = async () => {
  // 根据类型创建适配器
  const AdapterClass = mapType.value === 'amap' ? AMapAdapter : MapboxAdapter

  mapAdapter = new AdapterClass(mapContainer.value, {
    lng: 116.397428,
    lat: 39.90923,
    zoom: 12
  })

  await mapAdapter.init()
}

const switchMap = async () => {
  if (mapAdapter) {
    mapAdapter.destroy()
  }
  await initMap()
}

const addRandomMarkers = () => {
  for (let i = 0; i < 10; i++) {
    const lng = 116.397428 + (Math.random() - 0.5) * 0.1
    const lat = 39.90923 + (Math.random() - 0.5) * 0.1

    mapAdapter.addMarker(lng, lat, {
      title: `标记${i + 1}`,
      onClick: (marker) => {
        console.log('点击了标记', marker)
      }
    })
  }
}

const clearMarkers = () => {
  mapAdapter.clearMarkers()
}

onMounted(() => {
  initMap()
})

onUnmounted(() => {
  if (mapAdapter) {
    mapAdapter.destroy()
  }
})
</script>

<style scoped>
.unified-map {
  width: 100%;
  height: 600px;
  display: flex;
  flex-direction: column;
}

.map-controls {
  padding: 10px;
  background: #f0f0f0;
  display: flex;
  gap: 10px;
}

select, button {
  padding: 8px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}

button {
  background: #2196F3;
  color: white;
  border: none;
}

button:hover {
  background: #1976D2;
}

.map-container {
  flex: 1;
  position: relative;
}
</style>

5. 海量点标记优化

MarkerCluster.vue - 点聚合组件

vue
<template>
  <div ref="mapContainer" class="marker-cluster-map"></div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  points: { type: Array, required: true }
})

const mapContainer = ref(null)
let map = null
let markerCluster = null

onMounted(() => {
  // 初始化高德地图
  map = new AMap.Map(mapContainer.value, {
    center: [116.397428, 39.90923],
    zoom: 13
  })

  // 创建海量点
  const markers = props.points.map(point => ({
    lnglat: [point.lng, point.lat],
    name: point.name,
    id: point.id
  }))

  // 使用MarkerClusterer进行聚合
  map.plugin(['AMap.MarkerClusterer'], () => {
    markerCluster = new AMap.MarkerClusterer(map, markers, {
      gridSize: 80,
      renderClusterMarker: (context) => {
        const count = context.count
        const factor = Math.pow(count / 100, 1 / 5)

        const div = document.createElement('div')
        const size = Math.round(30 + factor * 20)

        div.style.cssText = `
          width: ${size}px;
          height: ${size}px;
          background-color: rgba(33, 150, 243, 0.8);
          border: 3px solid rgba(33, 150, 243, 1);
          border-radius: 50%;
          color: white;
          font-size: ${Math.round(size / 3)}px;
          font-weight: bold;
          display: flex;
          align-items: center;
          justify-content: center;
          cursor: pointer;
        `
        div.innerHTML = count

        context.marker.setOffset(new AMap.Pixel(-size / 2, -size / 2))
        context.marker.setContent(div)
      }
    })
  })
})
</script>

<style scoped>
.marker-cluster-map {
  width: 100%;
  height: 600px;
}
</style>

6. 自定义地图样式

MapStyleEditor.vue - 地图样式配置器

vue
<template>
  <div class="map-style-editor">
    <div class="editor-panel">
      <h3>地图样式编辑器</h3>

      <div class="style-group">
        <label>主题模式</label>
        <select v-model="styleConfig.theme" @change="applyStyle">
          <option value="light">明亮</option>
          <option value="dark">暗黑</option>
          <option value="blue">科技蓝</option>
          <option value="green">清新绿</option>
        </select>
      </div>

      <div class="style-group">
        <label>道路颜色</label>
        <input type="color" v-model="styleConfig.roadColor" @change="applyStyle" />
      </div>

      <div class="style-group">
        <label>建筑颜色</label>
        <input type="color" v-model="styleConfig.buildingColor" @change="applyStyle" />
      </div>

      <div class="style-group">
        <label>水体颜色</label>
        <input type="color" v-model="styleConfig.waterColor" @change="applyStyle" />
      </div>

      <div class="style-group">
        <label>背景颜色</label>
        <input type="color" v-model="styleConfig.backgroundColor" @change="applyStyle" />
      </div>

      <button @click="exportStyle">导出样式</button>
      <button @click="resetStyle">重置</button>
    </div>

    <div ref="mapContainer" class="preview-map"></div>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'

const mapContainer = ref(null)
let map = null

const styleConfig = reactive({
  theme: 'dark',
  roadColor: '#4a5568',
  buildingColor: '#2d3748',
  waterColor: '#2c5282',
  backgroundColor: '#1a202c'
})

const themePresets = {
  light: {
    roadColor: '#ffffff',
    buildingColor: '#f7fafc',
    waterColor: '#bee3f8',
    backgroundColor: '#edf2f7'
  },
  dark: {
    roadColor: '#4a5568',
    buildingColor: '#2d3748',
    waterColor: '#2c5282',
    backgroundColor: '#1a202c'
  },
  blue: {
    roadColor: '#2c5282',
    buildingColor: '#2a4365',
    waterColor: '#1e3a8a',
    backgroundColor: '#0f172a'
  },
  green: {
    roadColor: '#2f855a',
    buildingColor: '#276749',
    waterColor: '#065f46',
    backgroundColor: '#064e3b'
  }
}

const generateMapStyle = () => {
  return {
    styleJson: [
      {
        featureType: 'road',
        elementType: 'geometry',
        stylers: {
          color: styleConfig.roadColor
        }
      },
      {
        featureType: 'building',
        elementType: 'geometry',
        stylers: {
          color: styleConfig.buildingColor
        }
      },
      {
        featureType: 'water',
        elementType: 'geometry',
        stylers: {
          color: styleConfig.waterColor
        }
      },
      {
        featureType: 'background',
        elementType: 'geometry',
        stylers: {
          color: styleConfig.backgroundColor
        }
      }
    ]
  }
}

const applyStyle = () => {
  if (styleConfig.theme && themePresets[styleConfig.theme]) {
    Object.assign(styleConfig, themePresets[styleConfig.theme])
  }

  if (map) {
    map.setMapStyle({
      styleJson: generateMapStyle().styleJson
    })
  }
}

const exportStyle = () => {
  const style = generateMapStyle()
  const blob = new Blob([JSON.stringify(style, null, 2)], { type: 'application/json' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'map-style.json'
  a.click()
}

const resetStyle = () => {
  Object.assign(styleConfig, themePresets.dark)
  applyStyle()
}

onMounted(() => {
  map = new AMap.Map(mapContainer.value, {
    center: [116.397428, 39.90923],
    zoom: 13
  })

  applyStyle()
})
</script>

<style scoped>
.map-style-editor {
  display: flex;
  height: 600px;
}

.editor-panel {
  width: 300px;
  padding: 20px;
  background: #f5f5f5;
  overflow-y: auto;
}

.editor-panel h3 {
  margin: 0 0 20px 0;
}

.style-group {
  margin-bottom: 20px;
}

.style-group label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

.style-group select,
.style-group input[type="color"] {
  width: 100%;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

button {
  width: 100%;
  padding: 10px;
  margin-bottom: 10px;
  background: #2196F3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background: #1976D2;
}

.preview-map {
  flex: 1;
}
</style>

7. 坐标系转换工具

coordinateTransform.js - 坐标转换库

javascript
// utils/coordinateTransform.js

// 常量定义
const PI = Math.PI
const X_PI = (PI * 3000.0) / 180.0
const A = 6378245.0 // 长半轴
const EE = 0.00669342162296594323 // 偏心率平方

// 判断是否在中国境内
function outOfChina(lng, lat) {
  return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
}

// 转换纬度
function transformLat(lng, lat) {
  let ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat +
            0.1 * lng * lat + 0.2 * Math.sqrt(Math.abs(lng))
  ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0
  ret += (20.0 * Math.sin(lat * PI) + 40.0 * Math.sin(lat / 3.0 * PI)) * 2.0 / 3.0
  ret += (160.0 * Math.sin(lat / 12.0 * PI) + 320 * Math.sin(lat * PI / 30.0)) * 2.0 / 3.0
  return ret
}

// 转换经度
function transformLng(lng, lat) {
  let ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng +
            0.1 * lng * lat + 0.1 * Math.sqrt(Math.abs(lng))
  ret += (20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0 / 3.0
  ret += (20.0 * Math.sin(lng * PI) + 40.0 * Math.sin(lng / 3.0 * PI)) * 2.0 / 3.0
  ret += (150.0 * Math.sin(lng / 12.0 * PI) + 300.0 * Math.sin(lng / 30.0 * PI)) * 2.0 / 3.0
  return ret
}

// WGS84 转 GCJ02(GPS坐标转高德坐标)
export function wgs84ToGcj02(lng, lat) {
  if (outOfChina(lng, lat)) {
    return [lng, lat]
  }

  let dlat = transformLat(lng - 105.0, lat - 35.0)
  let dlng = transformLng(lng - 105.0, lat - 35.0)
  const radlat = lat / 180.0 * PI
  let magic = Math.sin(radlat)
  magic = 1 - EE * magic * magic
  const sqrtmagic = Math.sqrt(magic)
  dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * PI)
  dlng = (dlng * 180.0) / (A / sqrtmagic * Math.cos(radlat) * PI)
  const mglat = lat + dlat
  const mglng = lng + dlng
  return [mglng, mglat]
}

// GCJ02 转 WGS84(高德坐标转GPS坐标)
export function gcj02ToWgs84(lng, lat) {
  if (outOfChina(lng, lat)) {
    return [lng, lat]
  }

  let dlat = transformLat(lng - 105.0, lat - 35.0)
  let dlng = transformLng(lng - 105.0, lat - 35.0)
  const radlat = lat / 180.0 * PI
  let magic = Math.sin(radlat)
  magic = 1 - EE * magic * magic
  const sqrtmagic = Math.sqrt(magic)
  dlat = (dlat * 180.0) / ((A * (1 - EE)) / (magic * sqrtmagic) * PI)
  dlng = (dlng * 180.0) / (A / sqrtmagic * Math.cos(radlat) * PI)
  const mglat = lat + dlat
  const mglng = lng + dlng
  return [lng * 2 - mglng, lat * 2 - mglat]
}

// GCJ02 转 BD09(高德坐标转百度坐标)
export function gcj02ToBd09(lng, lat) {
  const z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * X_PI)
  const theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * X_PI)
  const bd_lng = z * Math.cos(theta) + 0.0065
  const bd_lat = z * Math.sin(theta) + 0.006
  return [bd_lng, bd_lat]
}

// BD09 转 GCJ02(百度坐标转高德坐标)
export function bd09ToGcj02(lng, lat) {
  const x = lng - 0.0065
  const y = lat - 0.006
  const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI)
  const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI)
  const gg_lng = z * Math.cos(theta)
  const gg_lat = z * Math.sin(theta)
  return [gg_lng, gg_lat]
}

// WGS84 转 BD09(GPS坐标转百度坐标)
export function wgs84ToBd09(lng, lat) {
  const gcj = wgs84ToGcj02(lng, lat)
  return gcj02ToBd09(gcj[0], gcj[1])
}

// BD09 转 WGS84(百度坐标转GPS坐标)
export function bd09ToWgs84(lng, lat) {
  const gcj = bd09ToGcj02(lng, lat)
  return gcj02ToWgs84(gcj[0], gcj[1])
}

// 自动检测坐标系并转换到目标坐标系
export function autoTransform(lng, lat, targetSystem = 'gcj02') {
  // 简单的坐标系判断逻辑
  // 实际项目中可能需要更复杂的判断

  // 如果坐标明显偏离中国,认为是WGS84
  if (outOfChina(lng, lat)) {
    return [lng, lat]
  }

  // 默认当作WGS84处理
  if (targetSystem === 'gcj02') {
    return wgs84ToGcj02(lng, lat)
  } else if (targetSystem === 'bd09') {
    return wgs84ToBd09(lng, lat)
  }

  return [lng, lat]
}

真实项目经验

我印象最深的是做物流车辆监控系统的地图功能。这个项目要实时显示全国5万多辆货车的位置,还要支持轨迹回放、围栏告警这些功能。

一开始我们直接用高德地图,把5万个车辆都加成marker。结果页面直接卡死了,浏览器都崩溃。我意识到这个量级必须做优化。

我做的第一个优化是点聚合。用高德的MarkerClusterer插件,把距离近的车辆合并显示。比如同一个区域有100辆车,就显示成一个数字"100"的标记,点击后才展开。这样屏幕上的marker数量就从5万降到了几百个,流畅多了。

但还有问题,就是地图移动或缩放的时候,聚合重新计算会卡顿。我又做了优化,把聚合计算放到Web Worker里,主线程只负责渲染。Worker计算好聚合结果传给主线程,用postMessage通信。这样就不阻塞UI了。

还有一个坑是坐标系问题。设备上报的GPS坐标是WGS-84,但高德地图用的是GCJ-02,两个坐标系有偏差。我直接把GPS坐标显示在地图上,发现车辆位置都偏了几百米,有的甚至跑到河里去了。后来我查资料知道国内地图都做了加密,必须要转换坐标系。我实现了坐标转换算法,GPS坐标先转成GCJ-02再显示,位置就准了。

地图样式也是个问题。客户要求地图要是暗色主题,跟他们系统风格一致。高德地图有几个内置样式,但都不满意。我研究了下高德的自定义样式API,可以通过JSON配置来定制地图的每个元素的颜色。我做了个可视化编辑器,可以实时预览效果,调好了导出JSON配置。这个工具后来其他项目也在用。

整个项目做下来,地图系统很稳定,5万辆车实时定位毫无压力,客户很满意。

使用说明

  1. 使用前需要申请对应地图平台的API Key
  2. MapAdapter提供统一接口,方便切换地图引擎
  3. 海量点标记建议使用聚合或Canvas绘制
  4. 坐标转换工具支持WGS84、GCJ02、BD09互转
  5. 自定义样式需要参考各平台的样式规范
  6. 生产环境建议做地图SDK的懒加载和降级处理