Vue 3 + 高德地图封装实战:点位、区域、Loca 流光与 Vue 信息窗体

15 阅读5分钟

Vue 3 + 高德地图封装实战:点位、区域、聚合、Loca 流光与 Vue 信息窗体

技术栈:Vue 3 + TypeScript + AMap JS API 2.0 + Loca 2.0

本文对高德地图能力做了统一封装,后续将按封装后的调用方式,整理后台地图页面常用功能及示例,并补充封装的底层实现思路。

一、封装能力概览

项目中的 TMap 封装提供以下方法:

TMap
├── initMap()
├── setMapCenter()
├── setMapStyle()
├── addMarker()
├── addMarkers()
├── setMarkerIcon()
├── markerDraggable()
├── addPolygons()
├── drawPolygonTool()
├── updatePolygon()
├── addPolylines()
├── setPolylineDisplay()
├── addPulseLayer()
├── removePulseLayer()
├── addCluster()
├── setClusterData()
├── createdInfoWindow()
├── openInfoWindow()
└── closeInfoWindow()

页面使用时:

import TMap from '@/utils/tmap'

const tmap = new TMap()

onMounted(() => {
  tmap.initMap('map-container', () => {
    initMapData()
  })
})

二、地图初始化

const tmap = new TMap()

tmap.initMap('map-container', () => {
  tmap.setMapStyle('amap://styles/your-style-id')
  tmap.setMapCenter([116.397428, 39.90923], 14)
})

底层使用 @amap/amap-jsapi-loader 加载 AMap 2.0:

AMapLoader.load({
  key,
  version: '2.0',
  plugins: [
    'AMap.MouseTool',
    'AMap.Geocoder',
    'AMap.PolygonEditor',
    'AMap.DistrictSearch',
    'AMap.PolylineEditor',
    'AMap.MarkerCluster'
  ],
  Loca: {
    version: '2.0.0'
  }
})

三、点位渲染

1. 使用封装添加点位

const pointList = [
  {
    id: 1,
    name: '一号点位',
    shape: [116.397428, 39.90923],
    icon: 'point-icon',
    color: '#2484f4'
  },
  {
    id: 2,
    name: '二号点位',
    shape: [116.407428, 39.91923],
    icon: 'point-icon',
    color: '#2484f4'
  }
]

tmap.addMarkers(
  pointList,
  (item) => {
    console.log('点击点位', item)
  },
  true
)

addMarkers() 内部会把配置转换成 AMap.Marker:

const marker = new AMap.Marker({
  position: item.shape,
  offset: new AMap.Pixel(-14, -32),
  content: this.dealMarkerIcon(item.icon, item.color, []),
  extData: item
})

marker.on('click', () => {
  onClick?.(item)
})

map.add(marker)

2. 修改点位图标和位置

tmap.setMarkerPosition([116.4, 39.91])
tmap.setMarkerIcon('point-active', '#00ffff', [36, 44])

3. 点位拖拽

tmap.markerSetDraggable(true)

tmap.markerDraggable((event) => {
  const lnglat = [event.lnglat.lng, event.lnglat.lat]

  tmap.regeoCode(lnglat, (addressInfo) => {
    console.log('拖拽后地址', addressInfo.address)
  })
})

四、区域绘制与渲染

1. 渲染区域

const areaList = [
  {
    id: 1,
    name: '示例区域',
    shape: [
      [116.39, 39.9],
      [116.41, 39.9],
      [116.41, 39.92],
      [116.39, 39.92]
    ],
    color: '#2484f4'
  }
]

tmap.addPolygons(
  areaList,
  (area) => {
    console.log('点击区域', area)
  },
  {
    show: true
  }
)

底层会创建:

const polygon = new AMap.Polygon({
  path: item.shape,
  fillColor: item.color,
  fillOpacity: 0.3,
  strokeColor: item.color,
  strokeWeight: 4,
  extData: item,
  zIndex: 50
})

2. 绘制区域

tmap.drawPolygonTool('#2484f4', (path) => {
  console.log('绘制完成', path)
})

3. 编辑区域

tmap.updatePolygon((path) => {
  const normalizedPath = path.map((point) => [
    Number(point.lng),
    Number(point.lat)
  ])
  console.log('编辑后的路径', normalizedPath)
})

五、行政区遮罩

tmap.maskCoverMapOtherArea({
  searchVal: '示例行政区',
  center: [116.397428, 39.90923],
  zoom: 14,
  fillColor: '#040b31',
  fillOpacity: 0.8,
  strokeColor: '#2484f4'
})

底层使用 AMap.DistrictSearch 获取行政区边界,再用一个带孔洞的多边形覆盖外部区域。

const district = new AMap.DistrictSearch({
  subdistrict: 1,
  extensions: 'all',
  level: 'district'
})

district.search(keyword, (status, result) => {
  if (status !== 'complete') return

  const holes = result.districtList[0].boundaries
  const outer = [
    new AMap.LngLat(-360, 90, true),
    new AMap.LngLat(-360, -90, true),
    new AMap.LngLat(360, -90, true),
    new AMap.LngLat(360, 90, true)
  ]

  map.add(new AMap.Polygon({
    path: [outer, ...holes],
    fillColor: '#040b31',
    fillOpacity: 0.8,
    strokeColor: '#2484f4',
    strokeWeight: 4
  }))
})

六、线路和 Loca 流光

1. 业务线路数据结构

interface FlowLine {
  id: string | number
  name: string
  shape: Array<[number, number]>
  color?: string
  width?: number
}

2. 添加基础 Polyline

const lineList: FlowLine[] = [
  {
    id: 1,
    name: '示例流向',
    shape: [
      [116.397428, 39.90923],
      [116.405428, 39.91523],
      [116.412428, 39.91923]
    ],
    color: '#00d8ff',
    width: 4
  }
]

tmap.addPolylines(lineList, (line) => {
  console.log('点击线路', line)
})

3. 将线路转换成 GeoJSON

Loca PulseLineLayer 需要标准 GeoJSON:

function formatGeoData(list: FlowLine[]) {
  return list.reduce((features: any[], line) => {
    if (line.shape?.length >= 2) {
      features.push({
        type: 'Feature',
        properties: {
          id: line.id,
          name: line.name,
          color: line.color || '#00d8ff',
          width: line.width || 4
        },
        geometry: {
          type: 'LineString',
          coordinates: line.shape
        }
      })
    }

    return features
  }, [])
}

const geo = {
  type: 'FeatureCollection',
  features: formatGeoData(lineList)
}

4. 使用封装添加流光

tmap.addPulseLayer(geo)

项目中的 addPulseLayer() 会创建 Loca 脉冲层:

addPulseLayer(geo: GeoJSON.FeatureCollection) {
  if (!this.pulseLayer) {
    this.pulseLayer = new this.Loca.PulseLineLayer({
      zIndex: 1000,
      opacity: 1,
      visible: true,
      zooms: [2, 22]
    })
  }

  this.pulseLayer.setStyle({
    altitude: 0,
    lineWidth: (_, feature) => Number(feature.properties.width),
    headColor: (_, feature) => feature.properties.color,
    trailColor: 'rgba(128,128,128,.5)',
    interval: 0.4,
    duration: 4000
  })

  this.pulseLayer.setSource(
    new this.Loca.GeoJSONSource({ data: geo })
  )

  this.loca.add(this.pulseLayer)
  this.loca.animate.start()
}

5. 新增一条流光线路

当业务数据增加一条线路时,将新线路合并到线路列表后重新生成 GeoJSON:

const currentLines: FlowLine[] = []

function addFlowLine(line: FlowLine) {
  currentLines.push(line)

  // 当前封装是整层替换,先移除旧脉冲层
  tmap.removePulseLayer()

  tmap.addPulseLayer({
    type: 'FeatureCollection',
    features: formatGeoData(currentLines)
  })
}

示例:

addFlowLine({
  id: 2,
  name: '新增流向',
  shape: [
    [116.397428, 39.90923],
    [116.417428, 39.92923]
  ],
  color: '#00e5ff',
  width: 5
})

6. 显示、隐藏和删除

// 同时控制普通 Polyline 和 Loca 脉冲层
tmap.setPolylineDisplay('show')
tmap.setPolylineDisplay('hide')

// 删除全部脉冲层并停止动画
tmap.removePulseLayer()

七、点位聚合

const markers = pointList.map((point) => {
  return new AMap.Marker({
    position: point.shape,
    extData: point
  })
})

tmap.addCluster(markers)

更新聚合数据:

tmap.setClusterData(newMarkers, 'reset')

追加数据:

tmap.setClusterData(newMarkers, 'add')

八、InfoWindow 挂载自定义 Vue 组件

1. 在父组件模板中渲染 Vue 组件

<div ref="infoWindowRef" class="map-info-window">
  <GeneralDetail
    :type="state.displayType"
    :info="state.displayData"
    @on-close="handleCloseInfoWindow"
    @on-check="state.showFullDetail = true"
  />
</div>

这里的关键是:

  • GeneralDetail 由 Vue 正常创建和挂载;
  • infoWindowRef.value 是一个真实的 DOM 容器;
  • TMap 只负责把这个 DOM 容器交给高德 InfoWindow。

2. 地图服务创建 InfoWindow

createdInfoWindow(content: HTMLElement, closeWhenClickMap = true) {
  this.infoWindow = new this.AMap.InfoWindow({
    isCustom: true,
    content,
    closeWhenClickMap,
    offset: new this.AMap.Pixel(0, -50)
  })
}

openInfoWindow(position: [number, number]) {
  this.infoWindow?.open(this.map, position)
}

3. 点击点位更新并打开

async function openPointInfoWindow(point) {
  state.displayType = 'point'
  state.displayData = point

  // 等待 Vue 把最新数据渲染到容器中
  await nextTick()

  if (!tmap.infoWindow) {
    tmap.createdInfoWindow(infoWindowRef.value, false)
  }

  tmap.openInfoWindow(point.shape)
}

由于 GeneralDetail 始终由 Vue 管理,更新 state.displayData 后,组件内部会正常响应式更新,不需要重新创建 Vue 实例。

4. 关闭和销毁

function handleCloseInfoWindow() {
  tmap.closeInfoWindow()
}

onBeforeUnmount(() => {
  tmap.closeInfoWindow()
  tmap.destroyInfoWindow()
})

5. 动态创建 Vue 组件作为 InfoWindow

如果 InfoWindow 内容不是模板中预先挂载的,而是运行时动态创建,可以这样处理:

import { createApp, h, reactive, nextTick } from 'vue'
import InfoPopup from './InfoPopup.vue'

let popupApp: ReturnType<typeof createApp> | null = null
let popupHost: HTMLElement | null = null
let popupWindow: AMap.InfoWindow | null = null

async function openDynamicInfoWindow(data: any, position: [number, number]) {
  closeDynamicInfoWindow()

  popupHost = document.createElement('div')
  const props = reactive({ data })

  popupApp = createApp({
    render() {
      return h(InfoPopup, {
        data: props.data,
        onClose: closeDynamicInfoWindow
      })
    }
  })

  popupApp.mount(popupHost)
  await nextTick()

  popupWindow = new AMap.InfoWindow({
    isCustom: true,
    content: popupHost,
    offset: new AMap.Pixel(0, -20)
  })

  popupWindow.open(map, position)
}

function closeDynamicInfoWindow() {
  popupWindow?.close()
  popupWindow = null

  popupApp?.unmount()
  popupApp = null
  popupHost = null
}

动态挂载时需要注意:

  • createApp 创建的是新的 Vue 应用实例;
  • 如果弹窗组件使用 Pinia、Router、i18n,需要给新应用安装对应插件,或把共享状态作为 props 传入;
  • 关闭 InfoWindow 后必须调用 unmount(),否则可能产生重复实例和内存泄漏;
  • 更推荐父组件预渲染 DOM 的方式,因为可以复用当前 Vue 应用上下文。

九、完整调用示例

import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import TMap from '@/utils/tmap'
import GeneralDetail from './GeneralDetail.vue'

const mapRef = ref()
const infoWindowRef = ref<HTMLElement>()
const tmap = new TMap()

const state = reactive({
  displayType: 'point',
  displayData: {}
})

onMounted(() => {
  tmap.initMap('map-container', async () => {
    tmap.setMapStyle('amap://styles/your-style-id')

    const points = await getPointList()
    const areas = await getAreaList()
    const lines = await getLineList()

    tmap.addMarkers(points, handlePointClick, true)
    tmap.addPolygons(areas, handleAreaClick)

    tmap.addPolylines(lines)
    tmap.addPulseLayer({
      type: 'FeatureCollection',
      features: formatGeoData(lines)
    })
  })
})

async function handlePointClick(point: any) {
  state.displayType = 'point'
  state.displayData = point

  await nextTick()

  if (!tmap.infoWindow) {
    tmap.createdInfoWindow(infoWindowRef.value!, false)
  }

  tmap.openInfoWindow(point.shape)
}

async function handleAreaClick(area: any) {
  state.displayType = 'area'
  state.displayData = area

  await nextTick()

  if (!tmap.infoWindow) {
    tmap.createdInfoWindow(infoWindowRef.value!, false)
  }

  tmap.openInfoWindow(getPolygonCenter(area.shape))
}

function getPolygonCenter(shape: Array<[number, number]>) {
  const [lng, lat] = shape.reduce(
    (total, point) => [total[0] + point[0], total[1] + point[1]],
    [0, 0]
  )

  return [lng / shape.length, lat / shape.length] as [number, number]
}

onBeforeUnmount(() => {
  tmap.removePulseLayer()
  tmap.closeInfoWindow()
  tmap.destroyInfoWindow()
})

十、功能与封装方法对照

功能封装方法
地图初始化initMap()
地图中心与缩放setMapCenter()
地图样式setMapStyle()
单点 MarkeraddMarker()
批量 MarkeraddMarkers()
点位拖拽markerDraggable()
逆地理编码regeoCode()
区域渲染addPolygons()
区域绘制drawPolygonTool()
区域编辑updatePolygon()
线路渲染addPolylines()
线路和流光显隐setPolylineDisplay()
添加流光addPulseLayer()
删除流光removePulseLayer()
点位聚合addCluster()
更新聚合setClusterData()
创建信息窗体createdInfoWindow()
打开信息窗体openInfoWindow()
关闭信息窗体closeInfoWindow()

总结

高德地图封装层主要负责四类能力:

  1. 地图实例和底图;
  2. Marker、Polygon、Polyline;
  3. Loca 流光和聚合;
  4. InfoWindow 与 Vue 组件协作。

其中 InfoWindow 使用自定义 Vue 组件时,最简单稳定的方式是先在父组件模板中渲染容器,再把容器 DOM 传给 AMap.InfoWindow。这样 Vue 的响应式、Pinia、Router 和组件样式都能正常复用。