Bootstrap

Vue中使用leaflet地图框架学习记录

前言

  • 中文文档地址
  • 项目最开始使用的是高德地图组件,不过为了兼容离线地图,那高德地图就用不了了,老大叫我去学习Leaflet,只能硬着头皮上了。
  • 网上关于Leaflet的教程很少,至少比关于高德的少得多,有些就写了怎么挂载地图实例,而且貌似大多都是用的基于vue封装的Vue2Leafet 组件
  • 关于高德地图的一些学习记录,点这里
  • 其实学习Leaflet框架已经大半年了,做前端的都是学了又忘-忘了又学,所以趁着还熟悉赶紧写写文档记下来
  • 在本文中,我尽量的与高德地图作对比,不过由于使用Leaflet后,高德地图我很久没有维护了,所以看看就好
  • 学习一个新东西的时候,啃官方文档是非常有必要的,建议先看一遍官方文档-1.6.0,不过只有英文,用浏览器自带翻译一下就好(PS:由于是国外网站,网不好有可能需要翻墙)
  • 先贴上html模板、实际地图效果
<template>
  <div class="leaflet-map-panel"
       ref="leaflet-map-panel"
       v-loading="loading">
    <!-- 地图容器 -->
    <div id="map-container"
         ref="map">
    </div>
    <!-- 搜索输入框 -->
    <poi-search @select="jumpToTargetMarker"></poi-search>
    <!-- 信息窗体 -->
    <div style="display:none">
      <infowindow ref="infowindow"
                  :deviceInfo="showDeviceInfo">
      </infowindow>
    </div>
  </div>
</template>

在这里插入图片描述

写在开头的友情提示 - Leaflet与高德最重要的一个区别就是:
传入经纬度时,一定是纬度(lat)在前经度(lng)在后!这个和高德的顺序是反的!!!
传入经纬度时,一定是纬度(lat)在前经度(lng)在后!这个和高德的顺序是反的!!!
传入经纬度时,一定是纬度(lat)在前经度(lng)在后!这个和高德的顺序是反的!!!


1 Leaflet安装

npm 安装

npm i leaflet --save

or
index.html加入以下代码

..
<body>
	...
    <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
    <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
	...
</body>
...

个人建议使用npm安装,因为我在使用index.html导入时,在发布版下会报“L未定义”的错误
官方文档


2 地图相关

2.1 图层结构分析

  • leaflet-map-pane: 地图层
    • leaflet-tile-pane: 瓦片数据层
    • leaflet-shadow-pane: 阴影层
    • leaflet-overlay-pane: 覆盖层?IE下拖动地图不松开的时候可以看见,具体不知道干啥的
    • leaflet-marker-pane: 标记点层
    • leaflet-tooltip-pane: 提示框层
    • leaflet-popup-pane: 弹出框层
    • leaflet-zoom-animated: 缩放动画层
  • leaflet-control-container: 内置控件层,如缩放控件这种
    在这里插入图片描述

2.2 创建/挂载地图实例

  • 这部分比较简单,'autopanstart’事件是信息窗体自适应移动产生的,对其进行了优化处理,防止在移动过程中触发地图相关事件
  • 官方文档

    // https://www.cnblogs.com/yingyigongzi/p/10810110.html
    // https://www.cnblogs.com/zhonglinke/p/11525965.html
    async mapInit() {
      try {
        // 第一个参数为元素id
        this.map.instance = new L.map('map-container', {
          // 顺序反的反的!!
          center: [this.mapParams.location.lat, this.mapParams.location.lng], // 初始化地图中心点
          zoom: 16, // 初始化地图层级
          minZoom: 8,
          zoomControl: false,
          attributionControl: false
        })

        this.map.markerLayer = new L.featureGroup()
        this.map.markerLayer.addTo(this.map.instance)

        this.map.otherLayer = new L.featureGroup()
        this.map.otherLayer.addTo(this.map.instance)

        // 绑定地图click事件
        this.map.instance.on('click', this.mapClick)
        // 绑定地图移动与缩放事件,就不贴出来了
        this.mapRegisterEvent()
        // 处理弹出窗自适应事件
        this.map.instance.on('autopanstart', () => {
          console.log('autopan start')
          this.flag.autoPaning = true
          this.mapUnregisterEvent()
          this.map.instance.once('moveend', () => {
            console.log('autopan end')
            this.flag.autoPaning = false
            this.mapRegisterEvent()
          })
        })
        this.initRecord.mapInit.value = true
      } catch (error) {
        console.error('mapInit error: ', error)
        this.$Message(`${this.$t('gisMap.map.error.init')}: ${error}`, 'error')
        this.initRecord.mapInit.value = false
        this.initRecord.mapInit.result = error
        throw error
      }
    },

2.3 加载瓦片层

  • 瓦片层实际上就是一堆png图片而已
  • 高德是不需要这个步骤的,内部已经给你处理了,Leaflet是需要手动添加的
  • 根据业务需求,做了三种情况处理:离线、在线、自定义地图
  • 对于在线地图(“Oneline”)类型,使用了Leaflet.ChineseTmsProviders插件,提供了大多常用的国内地图厂商URL,我在他的基础上进行了调整
  • 初学习的话只需要看Custom情况的处理即可,可直接使用注释中的内容
  • 友情提示:在线地图必须传入的参数subdomains;离线地图必须传入的参数:layers、format
  • 官方文档-在线地图官方文档-离线地图
// 在线地图
const tile = new L.tileLayer(
  'webrd0{s}.is.autonavi.com/appmaptile?&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
   { subdomains: [ '1', '2', '3', '4' ], format:'image/png'})
tile .addTo(地图实例)

// 离线地图
const wms = new L.tileLayer.wms(
      url,// wms地址
      options // 参数
)
wms.addTo(地图实例)
       
    async mapLoadTile() {
      try {
        switch (this.mapParams.useMapType) {
          case 'Offline':
            if (!this.mapParams.mapTileOptions['layers'])
              this.$Message(
                `'MapTileOptions.layers' ${this.$t(
                  'gisMap.map.warning.noCfg'
                )}`,
                'warning'
              )
            this.map.tileLayer = new L.tileLayer.wms(
              this.mapParams.getUrl(this.lang),
              this.mapParams.getOptions()
            )
            break
          case 'Online':
            if (!this.mapParams.mapTileOptions['subdomains']) {
              this.$Message(`'MapTileOptions.subdomains' ${this.$t( 'gisMap.map.warning.noCfg')}`,'warning')
            }
            this.mapParams.mapTileOptions.format = this.mapParams.mapTileOptions
              .format
              ? this.mapParams.mapTileOptions.format
              : 'image/png'
            this.map.tileLayer = new L.tileLayer(
              // 直接替换注释即可
              // '//webrd0{s}.is.autonavi.com/appmaptile?&size=1&scale=1&style=8&x={x}&y={y}&z={z}'
              this.mapParams.getUrl(this.lang),
              // { subdomains: [ '1', '2', '3', '4' ], format:'image/png'}
              this.mapParams.getOptions()
            )
            break
          default:
            throw `Unknow UseMapType '${this.mapParams.useMapType}'\r\nOnly support: 'Offline','Online','Custom'`
        }
        // 添加到地图
        this.map.tileLayer.addTo(this.map.instance)
        this.initRecord.tile.value = true
      } catch (error) {
        this.initRecord.tile.value = false
        if (error) {
          this.initRecord.tile.result = error
          this.$Message(
            `${this.$t('gisMap.map.error.loadTile')}: ${error}`,
            'error'
          )
        }
        throw error
      }
    },

2.4 加载遮罩层

  • 加载遮罩层必须要边界数组,在这里是从我们自己后台服务获取的,实际上也是从高德那边拿到的数据存到自己数据库中的;
  • 其实一开始是调用高德的AMap.DistrictSearch接口,考虑到内网部署后,改为调自己服务的接口了,边界数据在单独的工程管理中调用高德接口保存到数据库;
  • 如果你的项目可以访问互联网,可以参考我之前的文章AMap.DistrictSearch接口,稍微改改就行
  • 遮罩层请根据你的实际需求调整,我这里是填充边界的外多边形,内多边形则不需要outer、pathArray 变量,可以看下高德官方教程,列出了很多场景
  • 官方文档
// 为地图加载遮罩,只显示默认区域
    async mapLoadMask() {
      try {
        const re = await districtApi.getPolyline(this.mapParams.adCode)
        // 内多边形数组
        var boundaries = JSON.parse(re)
        // 外多边形数组
        var outer = [
          [90, -360],
          [-90, -360],
          [-90, 360],
          [90, 360]
        ]
        var pathArray = [outer]
        pathArray.push.apply(pathArray, boundaries)
        // https://leafletjs.com/reference-1.6.0.html#polyline
        var polygon = new L.polygon(pathArray, {
          // 线条颜色,使用16进制颜色代码赋值。默认值为#006600
          color: 'rgb(122,122,122)',
          // 线条宽度
          weight: 2,
          // 轮廓线透明度,取值范围[0,1],0表示完全透明,1表示不透明。默认为0.9
          opacity: 0.8,

          // 是否用颜色填充路径
          fill: true,
          // 多边形填充颜色,使用16进制颜色代码赋值,如:#FFAA00
          fillColor: 'rgba(250, 250, 250, 0.95)',
          // 多边形填充透明度,取值范围[0,1],0表示完全透明,1表示不透明。默认为0.9
          fillOpacity: 1
          // 轮廓线样式,实线:solid,虚线:dashed
          //strokeStyle: 'dashed',
          // 勾勒形状轮廓的虚线和间隙的样式,此属性在strokeStyle 为dashed 时有效, 此属性在
          // ie9+浏览器有效 取值:
          // 实线:[0,0,0]
          // 虚线:[10,10] ,[10,10] 表示10个像素的实线和10个像素的空白(如此反复)组成的虚线
          // 点画线:[10,2,10], [10,2,10] 表示10个像素的实线和2个像素的空白 + 10个像素的实
          // 线和10个像素的空白 (如此反复)组成的虚线
          // strokeDasharray: [0, 0, 0]
        })
        polygon.addTo(this.map.instance)

        console.log('蒙版绘制polygon完成')
        this.map.maskLayer = polygon
        this.initRecord.mask.value = true
      } catch (error) {
        this.initRecord.mask.value = false
        if (error) {
          this.initRecord.mask.result = error
          this.$Message(
            `${this.$t('gisMap.map.error.loadMask')}: ${error}`,
            'error'
          )
        } else {
          this.$Message(this.$t('gisMap.map.error.loadMask'), 'error')
        }
        throw error
      } finally {
        this.loading = false
      }
    },
  • 感觉Leaflet的遮罩刷新逻辑有点问题,拖动的时候不会立即刷新,要拖动结束才会刷新,不过影响不大吧 ,此问题已找到解决方法,在地图的move事件中加入以下代码,maskLayer是遮罩层(L.polygon)实例
this.map.instance.on('move', (e) => {
    if (this.map.maskLayer)
      // 会损失性能,自己斟酌把
       this.map.maskLayer._renderer._update()  //扒对象实例扒到的,_renderer是渲染出来的path
 })

在这里插入图片描述

2.5 地图移动/缩放事件处理

  • 这里我把moveend、zoomend两个事件绑定到了一个函数中处理
  • 对于移动距离小于设定值、地图正在处理其他事务时,不触发填充标记点
  • 官方文档
    mapRegisterEvent() {
      this.map.instance.on('moveend', this.mapViewChange)
      this.map.instance.on('zoomend', this.mapViewChange)
    },
    mapUnregisterEvent() {
      this.map.instance.off('moveend', this.mapViewChange)
      this.map.instance.off('zoomend', this.mapViewChange)
    },
    async mapViewChange() {
      const zoom = this.map.instance.getZoom() // 获取当前地图缩放级别
      const center = this.map.instance.getCenter() // 获取当前地图中心位置
      const boud = this.map.instance.getBounds()
      this.map.otherLayer.clearLayers()
      console.log('当前缩放级别:', zoom)
      console.log('当前中心位置:', center)
      console.log('当前边界:', boud)
      if (this.mapParams.searchDistance === 0) {
        ...
      } else if (this.mapParams.searchDistance === -1) {
        ...
      } else if (this.flag.clickMarker && this.flag.autoPaning) {
        return
      } else if (
        this.lastCenter.lat &&
        this.lastCenter.lng &&
        this.map.instance.distance(center, this.lastCenter) <=
          this.mapParams.minMoveDistance
      ) {
        console.log('移动的距离小于' + this.mapParams.minMoveDistance)
        return
      }
      this.fillMarkerDeb()
    },


3 标记点

3.1 创建/挂载标记点

  • L.marker的图标只有L.iconL.divIcon两种类型,L.icon扩展性太弱,这里我使用的是L.divIcon
  • L.divIcon的特点是可以自己设置内部html,这时候vue的组件化功能就可以派上用场了,在下面markerItem 就是一个vue组件
  • 创建一个标记点时,我为其增加了两个自定义属性,两个自定义方法,这个可以根据你的需求更改
  • 在这里我建议把riseOnHover设为true(鼠标移动到标记点时自动置顶),这个功能不错
  • 为何要组件化?往后看你就明白了
import _markerItem from '../components/markerItem'
const markerItem = Vue.extend(_markerItem)

    // ##标记点相关

    createMarker(deviceInfo) {
      // https://leafletjs.com/reference-1.6.0.html#marker
      const marker = new L.marker([deviceInfo.lat, deviceInfo.lng], {
        title: deviceInfo.name,
        // 鼠标在marker上方时,是否置顶
        riseOnHover: true
      })
      marker.data = deviceInfo
      marker.props = {
        isTop: false,
        loading: false,
        animationName: 'uiot-map-marker_add'
      }
      marker.setAnimation = function (name) {
        // if (this.props.animationName) {
        //   this._icon.classList.remove(this.props.animationName)
        // }
        this.props.animationName = `uiot-map-marker_${name}`
        // this._icon.classList.add(this.props.animationName)
      }
      marker.setLoading = function (val) {
        // console.log(this._icon)
        this.props.loading = val
      }
      marker.setTitle = function (title) {
        this._icon.setAttribute('title', title)
      }
      marker.show = function () {
        this._icon.style.display = ''
      }
      marker.hide = function () {
        this._icon.style.display = 'none'
      }
      marker.setTop = function () {
        if (this._map?.$lastTopMarker) {
          if (this._map.$lastTopMarker._leaflet_id === this._leaflet_id) {
            console.log('setTop 不用置顶', this._map.$lastTopMarker)
            return
          } else {
            console.log('setTop 还原', this._map.$lastTopMarker)
            this._map.$lastTopMarker.setZIndexOffset(0)
          }
        }
        this.setZIndexOffset(240)
        this._map.$lastTopMarker = this
      }
      const markerVue = new MarkerConstructor({
        propsData: {
          marker: marker
        }
      }).$mount()
      marker._vue = markerVue
      marker.setIcon(
        new L.divIcon({
          html: markerVue.$el
        })
      )
      marker.on('click', this.markerClick)
      marker.addTo(this.map.markerLayer)
      return marker
    },
  • L.marker是没有提供设置title的接口,比较可惜,如果你需要修改标记点title,建议使el-tooltip或者其他组件 ,最近发现是修改icon的dom节点的title属性即可,为标记点实例添加以下方法即可(或者手动调用_icon.setAttribute这个方法)(2020-11-20)
      marker.setTitle = function (title) {
        //_icon就是图标的dom节点
        this._icon.setAttribute('title', title)
      }

3.2 将标记点独立为组件

  • 就和写普通组件一样的,在我的高德教程里已经说过怎么组件化信息窗体了
  • 外层一个div包裹用于呈现动画、loading效果,内层img显示图标
  • marker.props.animationName是标记点动画名称,marker.props.loading设置是否呈现loading效果,后面会用到
<template>
  <div :class="['marker__wrap',marker.props.animationName]"
       v-loading="loading"
       ref="panel">
    <img :id="marker.data.id"
         :class="['marker-icon']"
         :src="markerIcon" />
  </div>
</template>
export default {
  name: 'MarkerItem',
  props: ['marker'],
  mounted() {
    // setTimeout(() => {
    //   if (this.marker.props.animationName === 'uiot-map-marker_add')
    //     this.marker.props.animationName = 'uiot-map-marker_none'
    // }, 500)
  },
  computed: {
    loading() {
      return this.marker.props.loading
    },
    markerIcon() {
      return getMarkerIcon(this.marker.data)
    }
  }
}
.leaflet-div-icon {
  margin: 0 !important;
  width: auto !important;
  height: auto !important;
  background: transparent !important;
  border: none !important;
  .marker-icon-panel {
    float: left;
    display: flex;
    // IE需要设置宽度或者最小宽度,不然显示不出来
    height: auto;
    width: auto;
    min-width: 22px;
    .marker-icon {
      flex: auto;
    }
  }
}

在这里插入图片描述

3.3 点击标记点置顶

  • 场景:如果一块区域内,一堆标记点重叠,用户点击一个标记点后,应当让这个标记点置顶
  • 在高德地图中,创建标记点时只需设置topWhenClick属性为true即可,如下

高德

  • 然而L.marker只有一个riseOnHover属性,开启该功能鼠标移至标记点上方后标记点将被置顶,想要实现点击置顶,则需要自己写
  • 实现此功能需要借助一个变量保存上一次被置顶的标记点,置顶时调用setZIndexOffset(偏移值),还原时调用setZIndexOffset(0)
  • 说下我遇到的坑: 在最开始做这个功能时,假如我置顶调用setZIndexOffset(500),还原时我就应该调用setZIndexOffset(-500),然而这样会出问题的,导致还原出错了,因为setZIndexOffset是修改的z-index的偏移量,调用setZIndexOffset(0)就是还原成默认的了
  • 搭配内置的riseOnHover一起使用非常nice
      // 代码见3.1的setTop方法
      marker.setTop = function () {
        if (this._map?.$lastTopMarker) {
          if (this._map.$lastTopMarker._leaflet_id === this._leaflet_id) {
            console.log('setTop 不用置顶', this._map.$lastTopMarker)
            return
          } else {
            console.log('setTop 还原', this._map.$lastTopMarker)
            this._map.$lastTopMarker.setZIndexOffset(0)
          }
        }
        this.setZIndexOffset(240)
        this._map.$lastTopMarker = this
      }
  • 效果如下

在这里插入图片描述

3.4 标记点动画/Loading

  • 使用组件化的目的就是为此,不然没法修改marker的class
  • 高德在这块是写好了的,直接设置animation属性就行,如下图,文档链接
    在这里插入图片描述
  • leaflet就需要自己写了,在3.1中我已经给标记点添加了props属性,在标记点组件的div中class也绑定了props.animationName的值,那么接下来只需要写css代码即可,我使用的是loading.io的动画库,在他的基础上做了修改
.uiot-map-marker_none {
  animation: none;
}
.uiot-map-marker_flashing {
  animation: flashing 400ms infinite;
  animation-fill-mode: forwards;
}
.uiot-map-marker_bounce {
  animation: bounce 1000ms infinite;
  animation-fill-mode: forwards;
}
  • 在需要使用动画的地方调用setAnimation方法,并在一段延时后取消动画(取决于你的动画时长)
let timeout = this.getAnimationTime(animation)
this.dicMarker[id].setAnimation(animation)
console.log('开始动画', this.dicMarker[id])
this.lastJumping.timer = setTimeout(() => {
	this.lastJumping.clear()
	console.log('取消动画', this.dicMarker[id])
	this.dicMarker[id].setAnimation('none')
	this.flag.jumpMarker = false
	//this.mapRegisterEvent()
	this.showMarker(this.dicMarker[id].data)
}, timeout)
  • 动画效果如下,有那种弹跳的伸缩效果了

在这里插入图片描述

  • 使用loading就很简单了,3.2中外层div的v-loading属性已经绑定了相关变量,调用标记点实例的setLoading方法即可
marker实例.setLoading(false or true)
  • Loading效果如下
    在这里插入图片描述


4 信息窗体

4.1 创建/挂载信息窗体

  • 在Leaflet中只有popup组件,实质上和高德是一样的
  • infowindow如何组件化?请看我的高德教程
  • 官方文档

    createInfoWindow() {
      // https://blog.csdn.net/qq_39512863/article/details/90483781
      // https://leafletjs.com/reference-1.6.0.html#popup
      this.mapInfoWindow = new L.popup({
        // 是否自动调整窗体到视野内(当信息窗体超出视野范围时,通过该属性设置是否自动平移地图,使信息窗体完全显示)
        autoPaning: true,
        // 开启keepInView,在切后台时可能会导致一直触发autupandstart事件,建议关闭
        // keepInView: true,
        // 弹出位置偏移量,可根据标记点图标大小修改
        offset: new L.point(10, 0)
      })
      console.log('createInfoWindow', this.$refs.infowindow)
      this.mapInfoWindow.setContent(this.$refs.infowindow.$el)
      // this.mapInfoWindow.on('popupclose', this.infoWindowClosed)
      this.map.instance.on('popupclose', this.infoWindowClosed)
    },

4.2 展示标记点信息

  • 请注意看finally中我的注释,很重要的一点,因为在打开信息窗体时,需要从后台获取扩展属性,那么填充后信息窗体的宽/高度就变了,如果不用nextTick,则获取到的高度是上一次高度,实际上vue的v-show指令也有这种情况(v-show就是控制display属性)
    async showMarker(info, latlng) {
      if (info && info.id) {
        if (
          this.dicMarker[info.id] &&
          this.dicMarker[info.id].props.loading === false
        ) {
          this.dicMarker[info.id].setLoading(true)
          this.mapInfoWindow.setLatLng(latlng ? latlng : [info.lat, info.lng])
          try {
            var extend = await markerApi.queryProps(info.id)
            this.showDeviceInfo.error = false
            this.showDeviceInfo.extend = extend ? extend : {}
          } catch (error) {
            console.error('showMarker error: ', error)
            this.showDeviceInfo.error = true
          } finally {
            this.showDeviceInfo.baseInfo = info
            if (this.dicMarker[info.id]) {
              this.dicMarker[info.id].setLoading(false)
            }
            this.$nextTick(() => {
              // 如果直接open, leaflet获取不到infowindow的实际高度的(获取到也是上一次的)
              // 所以一定要加上nextTick
              this.mapInfoWindow.update()
              this.mapInfoWindow.openOn(this.map.instance)
            })
          }
        }
      }
    },
  • 如果不加nextTick,就是下面这种情况
    • 第一次打开信息窗体,显示不全,因为扩展属性的高度没被计算进去,
    • 第二次打开你发现完全显示了,但是这并不是当前信息窗体的高度,而是上一次的,之所以完全显示,是因为上一次点击的标记点扩展属性也是三行,高度相同

在这里插入图片描述

后语

  • 有什么问题留言即可,如果这篇文章对你有用就点个赞把
  • 更新记录:
    • 2020-07-28 编写
    • 2020-11-20 标记点的title可修改,调用 $(marker实例)._icon.setAttribute方法,具体见标记点标题无法修改问题
    • 2020-11-20 遮罩层在地图移动时可更新,调用 $(遮罩层实例)._renderer._update方法,具体见遮罩层刷新问题
    • 2022-03-09 更新第三章
;