简历描述模板
负责公司地图可视化系统的技术选型与架构设计,对比评估了高德、百度、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. 地图适配器基类
MapAdapter.js - 统一地图接口
// 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 - 高德地图实现
// 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实现
// 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 - 可切换地图引擎的组件
<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 - 点聚合组件
<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 - 地图样式配置器
<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 - 坐标转换库
// 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万辆车实时定位毫无压力,客户很满意。
使用说明
- 使用前需要申请对应地图平台的API Key
- MapAdapter提供统一接口,方便切换地图引擎
- 海量点标记建议使用聚合或Canvas绘制
- 坐标转换工具支持WGS84、GCJ02、BD09互转
- 自定义样式需要参考各平台的样式规范
- 生产环境建议做地图SDK的懒加载和降级处理