微信小程序手绘地图实现之《Canvas》

环境:微信SDK2.9+

正题:

先创建一个地图组件

  1 <template>
  2   <view class="customCanvasComponent">
  3     <!-- 建立画布坐标系 -->
  4     <canvas
  5       :style="{
  6         width: `${options.style.width}rpx`,
  7         height: `${options.style.height}rpx`,
  8         border: options.style.border,
  9         background: options.style.background
 10       }"
 11       type="2d"
 12       :id="customMapId"
 13       :canvas-id="customMapId"
 14       @click="clickToCanvas"
 15       @touchstart="touchStartToCanvas"
 16       @touchmove="touchMoveToCanvas"
 17       @touchend="touchEndToCanvas">
 18       <!-- 由于微信限制 暂时只支持这种写法 请不要秀其他方式 否则凉凉 -->
 19       <!-- Marker点集合 -->
 20       <!-- <blank v-for="poi in handlerMarkerList" :key="poi.id">
 21         <cover-view
 22           class="point"
 23           @click="pointChange(poi)"
 24           :style="{
 25             position: ‘absolute‘,
 26             display: ‘flex‘,
 27             flexDirection: ‘column‘,
 28             alignItems: ‘center‘,
 29             left: poi.x + ‘px‘,
 30             top: poi.y + ‘px‘,
 31             transform: `translate(-50%, -100%)`
 32           }">
 33           <cover-image :style="poi.stringStyle" :src="poi.icon"></cover-image>
 34           <cover-view class="labelView" :style="poi.stringLabelStyle">
 35             <cover-view class="labelTitle">{{poi.label}}</cover-view>
 36           </cover-view>
 37         </cover-view>
 38       </blank> -->
 39       <!-- WindowInfo窗体设置 -->
 40       <blank v-if="checkPointMarker">
 41         <cover-view class="windowInfoGroupBox" :style="{
 42           position: ‘absolute‘,
 43           left: checkPointMarker.x + ‘px‘,
 44           top: checkPointMarker.y + ‘px‘,
 45           transform: `translate(-50%, calc(-100% - 90rpx))`
 46         }">
 47           <cover-view class="infoTitle">
 48             <cover-view class="infoVoiceBtn">
 49               <cover-image class="infoImage" :src="checkPointMarker.image"></cover-image>
 50               <cover-image class="playControl" src="https://weixin.xmzt.cn/static/scenic/tour_play@2x.png"></cover-image>
 51               <cover-image class="playControl" src="https://weixin.xmzt.cn/static/scenic/tour_pause@2x.png"></cover-image>
 52             </cover-view>
 53             <cover-view class="infoContent">
 54               <cover-view class="title otext2"></cover-view>
 55               <cover-view class="distance"></cover-view>
 56             </cover-view>
 57           </cover-view>
 58           <cover-view class="btnTools">
 59             <cover-view class="btn">
 60               <cover-image src="https://weixin.xmzt.cn/static/scenic/tour_poi_voice@2x.png"></cover-image>
 61               <cover-view class="btnText">解说</cover-view>
 62             </cover-view>
 63             <cover-view class="btn">
 64               <cover-image src="https://weixin.xmzt.cn/static/scenic/tour_poi_info@2x.png"></cover-image>
 65               <cover-view class="btnText">详情</cover-view>
 66             </cover-view>
 67           </cover-view>
 68         </cover-view>
 69       </blank>
 70       <!-- 预留控件 由于小程序限制机制 请使用时仅可使用*标签<cover-view><cover-image> -->
 71       <!-- 默认返回处理后的Marker点集合 -->
 72       <!-- ControlFirmware Left -->
 73       <slot name="control-l"/>
 74       <!-- ControlFirmware Right -->
 75       <slot name="control-r"/>
 76       <!-- ControlFirmware Top -->
 77       <slot name="control-t"/>
 78       <!-- ControlFirmware Bottom -->
 79       <slot name="control-b"/>
 80       <!-- 其他控件预留 -->
 81       <slot name="other"/>
 82       <!-- <cover-view class="toolsBox">
 83         <cover-view class="pointGroupBox">
 84           <blank>
 85             <cover-view v-for="poi in handlerMarkerList" :key="poi.id" class="point" :style="{position: ‘absolute‘, left: poi.x + ‘px‘, top: poi.y + ‘px‘}">
 86               <cover-image :style="{...poi.style}" :src="poi.icon"></cover-image>
 87               <cover-view class="labelView" :style="{...poi.labelStyle}">
 88                 <cover-view class="labelTitle">{{poi.label}}</cover-view>
 89               </cover-view>
 90             </cover-view>
 91           </blank>
 92         </cover-view>
 93         <cover-view class="windowInfoGroupBox">
 94           测试
 95           <cover-image style="" src="/static/images/scenic/tour_voice_poi_01@2x.png"></cover-image>
 96         </cover-view>
 97       </cover-view> -->
 98     </canvas>
 99     <!-- 建立与画布对应的平面坐标系 -->
100   </view>
101 </template>
102 
103 <script>
104 import CustomCavnasMap from ‘./map‘
105 let CustomMapInital = null
106 export default {
107   // 组件配置说明 必须基于某个地图提供商进行的适配  高德  百度  腾讯  谷歌
108   // 这里使用高德
109   props: {
110     // 部分配置参数
111     options: {
112       type: Object,
113       default: () => {
114         return {
115           // 样式层
116           style: {
117             // 宽高单位均为rpx
118             width: 750,
119             height: 1334,
120             // 背景支持色值或者网络图片背景图
121             background: ‘pink‘,
122             border: ‘none‘
123           },
124           // 坐标中心点 LngLat对象
125           center: [113.9120864868165, 22.545537650869],
126           // 地图范围 [LngLat, LngLat] 取点应为对角两个坐标 !!!注意坐标点位置 [右上<RT>, 左下<LB>]
127           limitBounds: [[113.914489746094, 22.54744015693], [113.909683227539, 22.543635144808]],
128           // 初始化地图层级
129           initalZoom: 16,
130           // 地图层级范围
131           zooms: [16, 18],
132           // 图层
133           layers: [
134             {
135               // 图片覆盖物 坐标范围  !!!注意坐标点位置 [右上<RT>, 左下<LB>]
136               limitBounds: [[113.914489746094, 22.54744015693], [113.909683227539, 22.543635144808]],
137               // 覆盖物地址
138               image: ‘https://xxx/static/map-bg.jpeg‘,
139               // 透明度
140               opacity: 1,
141               // 缩放范围
142               zooms: [16, 19]
143             }
144           ],
145           // 路线
146           lineStyle: {
147             lineWidth: 5,
148             lineColor: ‘red‘,
149             lineArray: []
150           },
151           // 自定义Marker
152           markers: [
153             {
154               icon: ‘/static/images/scenic/tour_voice_poi_01@2x.png‘,
155               position: [113.9128,22.544674],
156               style: {
157                 width: ‘93rpx‘,
158                 height: ‘105rpx‘,
159                 position: ‘relative‘,
160                 top: ‘60rpx‘
161               },
162               label: ‘(内测)城管大楼‘,
163               labelStyle: {
164                 position: ‘relative‘,
165                 top: ‘-90rpx‘,
166                 left: ‘50%‘,
167                 transform: ‘translateX(-50%)‘,
168                 background: ‘#FFF‘,
169                 padding: ‘5rpx 10rpx‘,
170                 fontSize: ‘28rpx‘
171               }
172             },
173             {
174               icon: ‘/static/images/scenic/tour_voice_poi_01@2x.png‘,
175               position: [113.911765,22.545397],
176               style: {
177                 width: ‘93rpx‘,
178                 height: ‘105rpx‘,
179                 position: ‘relative‘,
180                 top: ‘60rpx‘
181               },
182               label: ‘(内测)凉亭‘,
183               labelStyle: {
184                 position: ‘relative‘,
185                 top: ‘-90rpx‘,
186                 left: ‘50%‘,
187                 transform: ‘translateX(-50%)‘,
188                 background: ‘#FFF‘,
189                 padding: ‘5rpx 10rpx‘,
190                 fontSize: ‘28rpx‘
191               }
192             }
193           ]
194         }
195       }
196     },
197     // canvasId
198     customMapId: {
199       type: String,
200       default: ‘customMap‘
201     }
202   },
203   data () {
204     return {
205       // initalZoom: null,
206       // CustomMapInital: null, // 不要定义到data中 容易引发内存互换
207       handlerMarkerList: [],
208       checkPointMarker: null
209     }
210   },
211   watch: {
212     ‘options.lineStyle.lineArray‘: {
213       handler (_new, _old) {
214         if (_new !== _old) {
215           this.drawLine(_new)
216         }
217       },
218       deep: true
219     }
220   },
221   methods: {
222     initalCanvasMap () {
223       // console
224       CustomMapInital = new CustomCavnasMap({
225         customMapId: this.customMapId,
226         _component: this
227       }, Object.assign({}, this.options, {
228         markerCallBack: (list) => {
229           console.log(list)
230           this.handlerMarkerList = list
231         },
232         cilckPointChange: (info) => {
233           if (info) {
234             console.log(info)
235             console.log(‘得到点击成功后的触发‘)
236             this.pointChange(info)
237           } else {
238             console.log(‘得到点击空白的回调‘)
239           }
240         }
241       }))
256     },
257     fetchCustomBoxSize () {
258       nui.getImageInfo({
259         src: ‘‘,
260         success: (rect) => {
261           console.log(rect.fillPath[0])
262         }
263       })
264     },
265     /**
266      * @Function
267      * @public 公共类方法
268      * @return Object
269      */
270     // 设置缩放比例
271     setZoom (zoom, callback) {
272       // 最低限制为初始化的缩放比例
273       if (zoom > this.options.initalZoom) {
274         // 逻辑处理
275         CustomMapInital.setZoom(this.initalZoom, callback)
276       } else {
277         CustomMapInital.setZoom(zoom, callback)
278       }
279     },
280     // 获取缩放比例
281     getZoom (callback) {
282       if (callback) {
283         callback && callback(CustomMapInital.getZoom())
284       } else {
285         return CustomMapInital.getZoom()
286       }
287     },
288     /**
289      * 
290      * @touch 事件向this.CustomMapInital触发
291      */
292     touchStartToCanvas (e) {
293       CustomMapInital.touchStartToCanvas(e)
294     },
295     touchMoveToCanvas (e) {
296       CustomMapInital.touchMoveToCanvas(e)
297     },
298     touchEndToCanvas (e) {
299       CustomMapInital.touchEndToCanvas(e)
300     },
301     /**
302      * @click 事件向下触发
303      */
304     clickToCanvas (e) {
305       CustomMapInital.clickToCanvas(e)
306       // 点击其他地方进行清空WindowInfo窗体
307       this.checkPointMarker = null
308     },
309     /**
310      * @param {info<Object>} 类型为Marker数据对象
311      */
312     pointChange (info) {
313       this.checkPointMarker = info
314     },
315     /**
316      * @param {lineArray<Array|Object>} 传入的线路数据
317      * @param {Object} {longitude, latitude} 必须
318      */
319     drawLine (lineArray) {
320       CustomMapInital.drawLine(CustomMapInital.LngLatConversionToPixel(lineArray))
321     }
322   },
323   onReady () {
324     this.initalCanvasMap()
325   },
326   onUnload () {
327     CustomMapInital = null
328   }
329 }
330 </script>
331 
332 <style lang="sass" scoped>
333   $defaultBg: #FFF
334   $bgF4: #F4F4F4
335   $color3: #333
336   $color6: #666
337   $color9: #999
338   // $defaultBg: pink
339   // 取消默认样式
340   cover-view
341     overflow: initial !important
342   .customCanvasComponent
343     // .toolsBox
344     //   position: absolute
345     .point
346       position: absolute
347       z-index: -1
348       display: flex
349       flex-direction: column
350       align-items: center
351       .labelView
352         border-radius: 10rpx
353         background-color: $defaultBg
354         .labelTitle
355           font-size: 28rpx
356     .windowInfoGroupBox
357       background-color: $defaultBg
358       border-radius: 10rpx
359       width: 320rpx
360       height: 228rpx
361       box-shadow: 10rpx 10rpx 20rpx -10rpx $color6
362       display: flex
363       flex-direction: column
364       z-index: 99
365       .infoTitle
366         display: flex
367         align-items: center
368         padding: 20rpx
369         .infoVoiceBtn
370           width: 120rpx
371           height: 120rpx
372           flex: 0 0 120rpx
373           border: 1px solid $bgF4
374           border-radius: 50%
375           overflow: hidden
376           position: relative
377           cover-image
378             width: 100%
379             height: 100%
380             object-fit: contain
381           .playControl
382             position: absolute
383             width: 68rpx
384             height: 68rpx
385             top: 50%
386             left: 50%
387             transform: translate(-50%, -50%)
388         .infoContent
389           flex: 1
390           margin-left: 20rpx
391           .title
392             font-size: 28rpx
393             line-height: 28rpx
394             min-height: 56rpx
395             color: $color3
396             font-weight: bold
397             // margin-right: 58rpx
398             overflow: inherit
399           .distance
400             font-size: 22rpx
401             color: $color9
402             // margin-right: 0.58rem
403             margin-top: 10rpx
404       .btnTools
405         display: flex
406         flex: 1
407         .btn
408           flex: 0 0 calc(50% - 40rpx)
409           display: flex
410           margin: 0 20rpx 15rpx 20rpx
411           align-items: center
412           justify-content: center
413           border-radius: 30rpx
414           cover-image
415             width: 30rpx
416             height: 30rpx
417           .btnText
418             color: $defaultBg
419             font-size: 28rpx
420         .btn:nth-child(1)
421           background: #80D2FC
422           background: linear-gradient(#80D2FC, #188EE9)
423           background: linear-gradient(to right, #80D2FC, #188EE9)
424         .btn:nth-child(2)
425           background: #FBA326
426           background: linear-gradient(#FBA326, #FBA326)
427           background: linear-gradient(to right, #FBA326, #FBA326)
428 </style>

.map.js

  1 module.exports = class CustomCavnasMap {
  2   canvasContext = null
  3   // 定义背景装载图
  4   layersImages = []
  5   // 初始化Lock锁超出最大值停止初始化
  6   initLock = 0
  7   maxLockValue = 1000
  8   // 记录手指按下时的坐标 以及位置
  9   startingCoordinate = null
 10   // 旋转时中心点或者缩放时中心点 默认为画布起点
 11   rotateCenter = {
 12     x: 0,
 13     y: 0
 14   }
 15   // 背景图的偏移量
 16   offsetConfig = {
 17     mapX: 0,
 18     mapY: 0
 19   }
 20   // 捏合缩放倍数或者滚轮缩放倍数
 21   mapScale = 1
 22   // 捏合缩放状态
 23   mapZoom = false
 24   // 双指旋转角度地图旋转角度
 25   mapRotate = 0
 26   // 两指距离
 27   mapDistance = 0
 28   // 地图层级限制 最大值 默认两倍
 29   mapMaxZoom = 2
 30   // 地图层级限制 最小值 默认一倍
 31   mapMinZoom = 1
 32   // 惯性的运动距离 带方向的距离单位
 33   inertialMotion = {
 34     x: 0,
 35     y: 0
 36   }
 37   // 新增拖拽惯性支持 摩擦系数μs 范围应该在0-1之间
 38   us = 0.9
 39   // 惯性定时器
 40   inertialMotionTimer = null
 41   COMPUT_TIME = null
 42   // 图片预加载对象
 43   pictureExtractionObject = {}
 44   // 点击Canvas后的点位
 45   clickPoint = {
 46     x: 0,
 47     y: 0
 48   }
 49   // 点击触发后的状态 0未点击 1点击了 2点击了但是点击错了
 50   clickStatus = 0
 51   /**
 52    * @methods
 53    * @param {Object<customMapId,_component>} canvasOtions 画布对象
 54    * @param {Object<style,center,limitBounds,initalZoom,layers>} options 地图参数管控
 55    */
 56   constructor(canvasOtions, options) {
 57     // super(this)
 58     console.log(‘进入构造函数-->‘)
 59     // Object.keys(options)
 60     // 获取设备属性
 61     this.asyncFetchSystemInfo()
 62     // this.systemInfo = wx.getSystemInfoSync()
 63     // 属性继承
 64     Object.assign(this, canvasOtions, options)
 65     // 手动处理范围值
 66     this.zooms && (this.mapMaxZoom = this.zooms[1] - (this.initalZoom || this.zooms[0])) && (this.mapMinZoom = (this.zooms[0] - this.initalZoom) || 1)
 67     console.log(‘当前限制范围为:‘ + this.mapMinZoom + ‘-‘ + this.mapMaxZoom)
 68     // if (canvasOtions instanceof Object) {
 69     //   this.canvasContext = wx.createCanvasContext(canvasOtions.customMapId, canvasOtions._component)
 70     // } else {
 71     //   this.canvasContext = wx.createCanvasContext(canvasOtions.customMapId)
 72     // }
 73     // 设置分辨率
 74     // this.dpr = 1
 75     // 设置画布实际大小
 76     // this.canvasOptions = {
 77     //   width: parseInt(this.rpxToPx(options.style.width) * this.dpr),
 78     //   height: parseInt(this.rpxToPx(options.style.height) * this.dpr)
 79     // }
 80     // 获取Canvas节点元素
 81     this.wxCreateSelectorQuery().select(`#${canvasOtions.customMapId}`).fields({
 82       node: true,
 83       rect: true
 84     }, res => {
 85       // console.log(res)
 86       this.customCanvas = res.node
 87       // this.computedConversionData()
 88       // this.createMapBGImage(rect.node)
 89       this.dpr = this.systemInfo.pixelRatio
 90 
 91       // this.dpr = 1
 92       // 设置大小
 93       this.customCanvas.width = parseInt(this.rpxToPx(options.style.width) * this.dpr)
 94       this.customCanvas.height = parseInt(this.rpxToPx(options.style.height) * this.dpr)
 95       // 获取画布context上下文 2d
 96       this.ctxCanvas = this.customCanvas.getContext(‘2d‘)
 97       // 获取画布context上下文 webgl
 98       // this.glCanvas = this.customCanvas.getContext(‘webgl‘)
 99       // console.log(this.customCanvas)
100     }).exec()
101     // 开始初始化自定义地图
102     this.initalCanvasChange()
103   }
104   // 初始化Canvas画布对象
105   initalCanvasChange() {
106     if (this.customCanvas) {
107       this.computedConversionData()
108     } else {
109       setTimeout(() => {
110         console.log(‘设置延迟100ms进行渲染Canvas画布‘)
111         this.initLock++
112         this.initLock < this.maxLockValue && this.initalCanvasChange()
113       }, 100)
114     }
115   }
116   // 提供选择节点的公共方法
117   wxCreateSelectorQuery() {
118     if (this._component) {
119       return wx.createSelectorQuery().in(this._component)
120     } else {
121       return wx.createSelectorQuery()
122     }
123   }
124   // 计算两点坐标实际距离公式
125   GetDistance(LngLat1, LngLat2) {
126     var radLat1 = LngLat1[1] * Math.PI / 180.0
127     var radLat2 = LngLat2[1] * Math.PI / 180.0
128     var a = radLat1 - radLat2
129     var b = LngLat1[0] * Math.PI / 180.0 - LngLat2[0] * Math.PI / 180.0
130     var s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)))
131     s = s * 6378.137 // EARTH_RADIUS
132     s = Math.round(s * 10000) / 10000
133     return s
134   }
135   // 顺序构建map图库
136   createMapBGImage() {
144     // 清空页面绘制 2d
145     this.ctxCanvas.clearRect(0, 0, this.customCanvas.width, this.customCanvas.height)
146 
147     // 绘制canvas背景颜色
148     // this.ctxCanvas.fillStyle = this.style.background
149     // this.ctxCanvas.fillRect(0, 0, this.customCanvas.width, this.customCanvas.height)
150     // this.canvasContext.clearRect(0, 0, this.canvasOptions.width, this.canvasOptions.height)
151     // this.glCanvas.clear(this.glCanvas.COLOR_BUFFER_BIT)
152     // console.log(this.rotateCenter)
153     // 设置旋转中心点
154     this.ctxCanvas.translate(this.rotateCenter.x, this.rotateCenter.y)
155     // 对画布进行旋转 暂时关闭旋转
156     // this.ctxCanvas.rotate(this.mapRotate * Math.PI / 180)
157     // 当绘制结束后 还原旋转中心点
158     this.ctxCanvas.translate(-this.rotateCenter.x, -this.rotateCenter.y)
159     this.ctxCanvas.save()
160     // 循环进行处理图片 缩放 平移控制
161     this.layersImages.map(img => {
162       // console.log(img)
163       // 设置图片透明度
164       this.ctxCanvas.globalAlpha = img.opacity
169       this.ctxCanvas.drawImage(img, 0, 0, img.width, img.height, this.canvasLimitConfig.offsetLeft + this.offsetConfig.mapX * this.dpr, this.canvasLimitConfig.offsetTop + this.offsetConfig.mapY * this.dpr, this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr, this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr)
174       this.ctxCanvas.restore()
175     })
176     // 清除旋转角度
177     // this.ctxCanvas.rotate(this.mapRotate)
178     this.mapRotate = 0
179     // console.log(‘绘画完成‘)
180     // this.ctxCanvas.restore()
181     this.ctxCanvas.save()
182     this.COMPUT_TIME = new Date().getTime()
183     console.log(‘开始计算坐标点:‘ + this.COMPUT_TIME)
184     // 计算点
185     this.drawMarker(this.markers)
186   }
187   // 绘制Marker景点 传入参数MarkerList对象
188   drawMarker(infoList = []) {
189     // console.log(infoList)
190     if (infoList instanceof Array && infoList.length > 0) {
191       // 计算之前 先得到图标
192       if (Object.keys(this.pictureExtractionObject).length > 0) {
193         // 开始绘制
194         // 使用定位解决方案 避免canvas数据量过大造成卡顿 [定位方案更卡。。。]
195         // this.LngLatToPixel()
196         this.handlerMarkerList = infoList.map((item, index) => {
197           item.stringStyle = ‘‘
198           Object.keys(item.style).map(key => {
199             item.stringStyle += `${key}: ${item.style[key]};`
200           })
201           item.stringLabelStyle = ‘‘
202           Object.keys(item.labelStyle).map(key => {
203             item.stringLabelStyle += `${key}: ${item.labelStyle[key]};`
204           })
207           return Object.assign(item, this.LngLatToPixel(item.position), {id: index})
208         })
209         // 创建ICON图标
211         this.handlerMarkerList.map(item => {
212           this.ctxCanvas.beginPath()
213           this.ctxCanvas.arc(item.canvasX, item.canvasY, 5, 0, 2 * Math.PI)
214           this.ctxCanvas.strokeStyle = ‘red‘
215           this.ctxCanvas.fillStyle = ‘pink‘
216           this.ctxCanvas.fill()
217           this.ctxCanvas.stroke()
218           this.ctxCanvas.restore()
220           const w = this.rpxToPx(parseInt(item.style.width)) * this.dpr
221           const h = this.rpxToPx(parseInt(item.style.height)) * this.dpr
222           this.ctxCanvas.drawImage(this.pictureExtractionObject[item.icon], item.canvasX - w / 2, item.canvasY - h / 3 * 2, w, h)
223           this.ctxCanvas.restore()
224           this.ctxCanvas.rect(item.canvasX - w / 2, item.canvasY - h / 3 * 2, w, h)
225           const clickPointX = this.clickPoint.x * this.mapScale * this.dpr + this.offsetConfig.mapX * this.dpr + this.canvasLimitConfig.offsetLeft
226           const clickPointY = this.clickPoint.y * this.mapScale * this.dpr + this.offsetConfig.mapY * this.dpr + this.canvasLimitConfig.offsetTop

229           if (this.clickStatus !== 0) {
230             if (this.ctxCanvas.isPointInPath(clickPointX, clickPointY)) {
231               this.cilckPointChange(item)
232               this.clickStatus = 1
233               console.log(‘成功触发画布点击回调‘)
234             } else {
235               console.log(‘点位错误‘)
236             }
237           }
238         })
239         if (this.clickStatus === 2) {
240           // 触发未点中的回调
241           this.cilckPointChange()
242         }
243         // console.log(this.handlerMarkerList)
244         const END_TIME = new Date().getTime()
245         
246         console.log(‘计算结束:‘ + (END_TIME - this.COMPUT_TIME))
247         this.markerCallBack(this.handlerMarkerList)
248       } else {
249         setTimeout(() => {
250           this.drawMarker(infoList)
251         }, 100)
252       }
253     }
254   }
255   LngLatConversionToPixel (LngLatArray = []) {
256     if (LngLatArray instanceof Array && LngLatArray.length > 0) {
257       return LngLatArray.map((item, index) => {
258         return Object.assign(item, this.LngLatToPixel([item.longitude, item.latitude]), {id: index})
259       })
260     }
261   }
262   // 绘制线路
263   drawLine(LinePathArray = []) {
264     if (LinePathArray instanceof Array && LinePathArray.length > 0) {
265       // 设置绘制样式
266       this.ctxCanvas.strokeStyle = this.lineStyle.lineColor || ‘#000000‘
267       this.ctxCanvas.lineWidth = this.lineStyle.lineWidth || 5
268       // 开始绘制
269       LinePathArray.map((line, index) => {
270         if (index === 1) {
271           this.ctxCanvas.moveTo(line.x, line.y)
272         } else {
273           this.ctxCanvas.lineTo(line.x, line.y)
274         }
275       })
276       this.ctxCanvas.stroke()
277       // 绘制结束
278       // 保存一次
279       this.ctxCanvas.save()
280     }
281   }
282   // 取中心点方法
283   Vector(vector1, vector2) {
284     this.x = vector2.x - vector1.x
285     this.y = vector2.y - vector1.y
286   }
287   // 计算点乘 => 公式:a↑ * b↑ = |a↑||b↑|cosθ
288   // 其中:a↑ * b↑ = x1*x2 + y1*y2
289   // 模计算:|a↑| = Math.sqrt(x1 ** 2 + y1 ** 2)
290   calculateVM(vector1, vector2) {
291     return (vector1.x * vector2.x + vector1.y * vector2.y) / (Math.sqrt(vector1.x ** 2 + vector1.y ** 2) * Math.sqrt(vector2.x ** 2 + vector2.y ** 2))
292   }
293   // 计算叉乘
294   calculateVC(vector1, vector2) {
295     return (vector1.x * vector2.y - vector2.x * vector1.y) > 0 ? 1 : -1
296   }
297   // 获取系统信息
298   asyncFetchSystemInfo() {
299     this.systemInfo = wx.getSystemInfoSync()
300   }
301   // rpx转px
302   rpxToPx(v) {
304     return v / 750 * this.systemInfo.windowWidth
305   }
306   // 初始化需要计算的所有数据
307   computedConversionData() {
309     // 排序提取背景覆盖物的值
310     this.handlerImages = this.layers.map(item => {
311       !item.zIndex && (item.zIndex = 100)
312       return item
313     }).sort((a, b) => {
314       return a.zIndex - b.zIndex
315     }).filter(fs => fs)
318 
319     // 对角坐标计算 => 转成4个 [LT, RT, RB, LB] 顺时针顺序
320     if (this.limitBounds.length === 2) {
321       this.mapCanvasBoxLngLats = [
322         [this.limitBounds[1][0], this.limitBounds[0][1]],
323         this.limitBounds[0],
324         [this.limitBounds[0][0], this.limitBounds[1][1]],
325         this.limitBounds[1]
326       ]
327       // 得到转化后的坐标进行计算实际距离
329       const width = this.GetDistance(this.mapCanvasBoxLngLats[0], this.mapCanvasBoxLngLats[1])
330       const height = this.GetDistance(this.mapCanvasBoxLngLats[1], this.mapCanvasBoxLngLats[2])
333       const viewWidth = this.rpxToPx(this.style.width || 750)
334       const viewHeight = parseInt(height * viewWidth / width)
335 
336       this.canvasLimitConfig = {
337         proportionX: viewWidth / width,
338         proportionY: viewHeight / height,
339         width,
340         height,
341         viewWidth,
342         viewHeight,
345         offsetTop: parseInt(Math.abs((this.customCanvas.height / this.dpr - viewHeight) / 2)),
346         offsetLeft: parseInt(Math.abs((this.customCanvas.width / this.dpr - viewWidth) / 2))
347       }
350     }
351 
352     // 图片加载处理
353     this.handlerImages.map(item => {
355       const img = this.customCanvas.createImage()
359       img.onload = (e) => {
360         // console.log(‘已成功加载图片---->‘)
362         // 设置附件值
363         Object.assign(img, item)
366         this.layersImages.push(img)
369         this.createMapBGImage()
371         // console.log(‘设置图片完成‘)
372       }
373       img.onerror = (e) => {
374         console.log(e)
375         img.src = item.image
376       }
377       img.src = item.image
389     })
390     // ICON预加载
394     this.pictureExtraction(this.markers, ‘icon‘).map(item => {
395       const image = this.customCanvas.createImage()
396       image.onload = (e) => {
398         this.pictureExtractionObject[item] = image
399       }
400       image.onerror = (e) => {
401         image.src = item
402       }
403       image.src = item
404     })
405   }
406   /**
407    * 其他辅助类函数
408    * @method deepClone 深度克隆 
409    * @param {Any} Any 任意类型
410    * 
411    * 对一个object进行深度拷贝
412    *
413    * 使用递归来实现一个深度克隆,可以复制一个目标对象,返回一个完整拷贝
414    * 被复制的对象类型会被限制为数字、字符串、布尔、日期、数组、Object对象。不会包含函数、正则对象等
415    *
416    * @param  {Object} ObjectSource 需要进行拷贝的对象
417    */
418   deepClone(ObjectSource) {
419     if (Array.isArray(ObjectSource)) {
420       return Object.assign([], ObjectSource)
421     }
422     return Object.assign({}, ObjectSource)
423   }
424   /**
425    * 
426    * @param {Array<Object>} imageArray 传入数组遍历对象
427    * @param {String} name 需要指定去重的数据名称
428    * @return {Array} 返回的是去重后的Image数组
429    */
430   pictureExtraction (imageArray, name) {
431     let cloneImageObject = {}
432     imageArray.map(item => {
433       cloneImageObject[item[name]] = item[name]
434     })
435     return Object.keys(cloneImageObject)
436   }
437   /**
438    * @touch 事件处理
439    * @param {Event} e Event对象
440    */
441   touchStartToCanvas(e) {
442     // 操作开始时 清空处理
443     this.inertialMotionTimer && clearInterval(this.inertialMotionTimer)
444     // 多指处理
445     if (e.touches.length > 1) {
446       // 属于多指操作类型
447       console.log(‘当前属于多指操作‘)
448       // console.log(e)
449       // 计算并存储数据
450       const xMove = e.touches[1].x - e.touches[0].x
451       const yMove = e.touches[1].y - e.touches[0].y
452       // 计算两指距离
453       this.mapDistance = Math.sqrt(xMove ** 2 + yMove ** 2)
454       this.thisCoordinate = e.touches
455       this.startingCoordinate = e.touches
456       this.mapZoom = true
457     } else {
458       this.startingCoordinate = e.touches[0]
459       // 初始化惯性速度
460       this.inertialMotion = {
461         x: 0,
462         y: 0
463       }
464     }
465   }
466   touchMoveToCanvas(e) {
467     if (e.touches.length > 1) {
468       // 属于多指操作类型
469       console.log(‘当前属于多指操作‘)
470       this.mapZoom = true
472       // 计算旋转
473       const preCoordinate = this.deepClone(this.startingCoordinate)
475       this.startingCoordinate = e.touches
476       const vector1 = new this.Vector(preCoordinate[0], preCoordinate[1])
477       const vector2 = new this.Vector(this.startingCoordinate[0], this.startingCoordinate[1])
479       const resultCosVal = this.calculateVM(vector1, vector2)
480       // 弧度换算成角度
481       const angle = Math.acos(resultCosVal) * 180 / Math.PI
482 
483       const direction = this.calculateVC(vector1, vector2)
484       // 得到最后的旋转度数
485       const _allDeg = direction * angle
488 
489       // 双指缩放
490       const xMove = e.touches[1].x - e.touches[0].x
491       const yMove = e.touches[1].y - e.touches[0].y
492 
493       // 取中心点
494       const posCenter = this.rotateCenter = {
495         x: (e.touches[0].x + e.touches[1].x) / 2,
496         y: (e.touches[0].y + e.touches[1].y) / 2
497       }
498 
499       const distance = Math.sqrt(xMove ** 2 + yMove ** 2)
500       const distanceDiff = distance - this.mapDistance
502       const scalingIndex = 0.005 * distanceDiff
503       const newScale = this.mapScale + scalingIndex

509       let mapX = this.offsetConfig.mapX
510       let mapY = this.offsetConfig.mapY
514 
515       const scaleSizeX = scalingIndex * this.canvasLimitConfig.viewWidth * this.mapScale
516       const scaleSizeY = scalingIndex * this.canvasLimitConfig.viewHeight * this.mapScale
517 
518       mapX -= scaleSizeX / 2
519       mapY -= scaleSizeY / 2
520       console.log(‘多指‘)
535 
536       if (Math.abs(_allDeg) > 1) {
537         this.mapRotate = this.mapRotate + _allDeg
538         // 重绘
539         this.createMapBGImage()
540       }
541       // 限制范围 不存在mapX mapY时出现计算错误时退出当前缩放
542       if (newScale < this.mapMinZoom || newScale > this.mapMaxZoom || isNaN(mapX) || isNaN(mapY)) {
543         return
544       }
545       this.mapDistance = distance
546       this.mapScale = newScale
547       this.offsetConfig.mapX = mapX
548       this.offsetConfig.mapY = mapY
549       // 重绘
550       this.createMapBGImage()
551     } else {
552       // slidingDistanceX
553       // const offsetX = 
554       // 不处理在双指或者多指情况下的剩余操作
555       if (this.mapZoom) {
556         return
557       }
558       // 判断是否为数组
559       if (this.startingCoordinate instanceof Array) {
560         this.startingCoordinate = this.startingCoordinate[0]
561       }
562       const thisCoordinate = e.touches[0]
563       const slidingDistanceX = thisCoordinate.x - this.startingCoordinate.x
564       const slidingDistanceY = thisCoordinate.y - this.startingCoordinate.y
565 
566       this.offsetConfig.mapX += slidingDistanceX
567       this.offsetConfig.mapY += slidingDistanceY
568       // 处理速度
569       this.inertialMotion = {
570         x: slidingDistanceX || 0,
571         y: slidingDistanceY || 0
572       }
573       // console.log(this.inertialMotion)
574       console.log(‘单指‘)
575       // console.log(this.inertialMotion.x, this.inertialMotion.y)
576       // 处理边界
577       this.touchMoveLimitBounds()
578       
579       // 重新设置初始点
580       this.startingCoordinate = thisCoordinate
581       // 重绘
582       this.createMapBGImage()
583     }
584   }
585   touchEndToCanvas(e) {
586     // console.log(e)
587     if (e.touches.length === 0) {
588       // 处理惯性
589       !this.mapZoom && this.inertialMotionToCanvas(this.inertialMotion.x, this.inertialMotion.y)
590       this.mapZoom = false
591       // 如果初始大小 则复位
592       if (this.mapScale === 1) {
593         // this.offsetConfig = {
594         //   mapX: 0,
595         //   mapY: 0
596         // }
597         // 重绘
598         // this.createMapBGImage()
599       }
600       // 处理用户多指操作 抬起某一手指 应进行删除控制
601       // e.touches.map(item => {
602 
603       // })
604     } else {
605       // console.log(e)
606       this.mapZoom = false
607     }
608   }
609   touchMoveLimitBounds() {
610     // 处理边界问题
611     // X 轴
612     if ((this.offsetConfig.mapX + this.canvasLimitConfig.offsetLeft + this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr) > this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr) {
613       this.offsetConfig.mapX = this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr - this.canvasLimitConfig.offsetLeft - this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr
614     } else if ((this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr + this.offsetConfig.mapX * this.dpr) < this.customCanvas.width) {
615       this.offsetConfig.mapX = (this.customCanvas.width - this.canvasLimitConfig.viewWidth * this.mapScale * this.dpr) / this.dpr
616     }
617     // Y 轴
618     if (this.customCanvas.height > this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr) {
619       if ((this.customCanvas.height - this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr) < (this.offsetConfig.mapY * this.dpr + this.canvasLimitConfig.offsetTop)) {
620         this.offsetConfig.mapY = (this.customCanvas.height - this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr - this.canvasLimitConfig.offsetTop) / this.dpr
621       } else if ((this.canvasLimitConfig.offsetTop + this.offsetConfig.mapY * this.dpr) < 0) {
622         this.offsetConfig.mapY = (0 - this.canvasLimitConfig.offsetTop) / this.dpr
623       }
624     } else {
625       if ((this.customCanvas.height - this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr) > (this.offsetConfig.mapY * this.dpr + this.canvasLimitConfig.offsetTop)) {
626         this.offsetConfig.mapY = (this.customCanvas.height - this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr - this.canvasLimitConfig.offsetTop) / this.dpr
627       } else if ((this.canvasLimitConfig.offsetTop + this.offsetConfig.mapY * this.dpr) > 0) {
628         this.offsetConfig.mapY = (0 - this.canvasLimitConfig.offsetTop) / this.dpr
629       }
630     }
631   }
632   /**
633    * 处理拖动惯性运动
634    * @param {Number} speedX X轴的速度
635    * @param {Number} speedY Y轴的速度
636    * @handler Canvas 处理函数
637    */
638   inertialMotionToCanvas(speedX, speedY) {
639     if (isNaN(speedX) || isNaN(speedY)) return
640     this.inertialMotionTimer && clearInterval(this.inertialMotionTimer)
641     this.inertialMotionTimer = setInterval(() => {
642       speedX *= this.us
643       speedY *= this.us
644       this.offsetConfig.mapX += speedX
645       this.offsetConfig.mapY += speedY
646       // 处理边界
647       this.touchMoveLimitBounds()
648       if (Math.abs(speedX) < 1) speedX = 0
649       if (Math.abs(speedY) < 1) speedY = 0
650       if (speedX == 0 && speedY == 0) {
651         this.inertialMotion = {
652           x: 0,
653           y: 0
654         }
655         clearInterval(this.inertialMotionTimer)
656       }
658       // 重绘
659       this.createMapBGImage()
660     }, 30)
661   }
662 
663   /**
664    * @click 事件处理
665    * @param {Event} e Event对象
666    */
667   clickToCanvas(e) {
669     // 假设没点中
670     this.clickStatus = 2
671     this.clickPoint = {
672       x: e.target.x - e.target.offsetLeft,
673       y: e.target.y - e.target.offsetTop
674     }
675     this.createMapBGImage()
676   }
677   /**
678    * 坐标换算
679    *           P (a)
680    * D ┍━━━━━━━┳━━━━━━━━┒ A
681    *    ╲      ┃       ╱
682    *     ╲     ┃      ╱
683    *      ╲    ┃h    ╱
684    *     c ╲   ┃    ╱ b
685    *        ╲  ┃   ╱
686    *         ╲ ┃  ╱
687    *          ╲┃ ╱
688    *           ┻  O
689    * 从地图坐标系到物理坐标戏
690    * @methods LngLatToPixel {LngLat<Array|Number>} []
691    * @return {Object<x, y>} {x, y}
692    */
693   LngLatToPixel (LngLat) {
694     const DO = this.GetDistance(this.mapCanvasBoxLngLats[0], LngLat)
695     const DA = this.canvasLimitConfig.width
696     const AO = this.GetDistance(this.mapCanvasBoxLngLats[1], LngLat)
701     const PixelPoint = this.TargetTriangleAreaToXY_Heiht(DA, AO, DO, DA)
702     return PixelPoint
703   }
704   /**
705    * 目标三角形面积计算
706    * @methods TargetTriangleArea {a, b, c} 三角形三边长
707    * 原理 海伦定理 S = Math.sqrt(p(p-a)(p-b)(p-c)) 其中 p = (a + b + c) / 2
708    */
709   TargetTriangleArea(a, b, c) {
710     const p = (a + b + c) / 2
711     return Math.sqrt(p * (p - a) * (p - b) * (p - c))
712   }
713   /**
714    * 目标三角形的高
715    * @methods TargetTriangleAreaToHeiht {a, b, c, xh} 三角形三边长 加对应需求解的底边xh
716    * 原理 S = 1/2 AH 其中A代表底边 H代表底边对应的高
717    * @return {Number} 对应底边的高
718    */
719   TargetTriangleAreaToHeiht(a, b, c, xh) {
720     return 2 * this.TargetTriangleArea(a, b, c) / xh
721   }
722   /**
723    * 计算XY值 即底边垂线 DP PA值
724    * @param {a, b, c, xh} 注意区分大a边为DA 大b边为AO  大c边为DO
725    *           P (a)
726    * D ┍━━━━━━━┳━━━━━━━━┒ A
727    *    ╲      ┃       ╱
728    *     ╲     ┃      ╱
729    *      ╲    ┃h    ╱
730    *     c ╲   ┃    ╱ b
731    *        ╲  ┃   ╱
732    *         ╲ ┃  ╱
733    *          ╲┃ ╱
734    *           ┻  O
735    * @return {x, y} 返回值以原点为坐标的坐标点
736    */
737   TargetTriangleAreaToXY_Heiht(a, b, c, xh) {
739     const H = this.TargetTriangleAreaToHeiht(a, b, c, xh)
740     const hcReg = Math.acos(H / c)
745     const DP = c * Math.sin(hcReg)
748     return {
749       canvasX: this.canvasLimitConfig.proportionX * DP * this.mapScale * this.dpr + this.offsetConfig.mapX * this.dpr + this.canvasLimitConfig.offsetLeft,
750       canvasY: this.canvasLimitConfig.proportionY * H * this.mapScale * this.dpr + this.offsetConfig.mapY * this.dpr + this.canvasLimitConfig.offsetTop,
751       x: this.canvasLimitConfig.proportionX * DP * this.mapScale + this.offsetConfig.mapX + this.canvasLimitConfig.offsetLeft / this.dpr,
752       y: this.canvasLimitConfig.proportionY * H * this.mapScale + this.offsetConfig.mapY + this.canvasLimitConfig.offsetTop / this.dpr,
753       zx: DP,
754       zy: H
755     }
756   }
757   /**
758    * 计算实际值与像素值的动态倍率
759    * @method ActualScalingIndex
760    * @return {scale<Number>} 返回真实的缩放数值 单位:米/像素 m/pixel
761    */
762   ActualScalingIndex() {
764     // 获取实长
765     const ActualWidth = this.canvasLimitConfig.viewHeight * this.mapScale * this.dpr
766     return this.canvasLimitConfig.width * 1000 / ActualWidth
767   }
768 }

至此结束

数据格式解析

{
          // 样式层
          style: {
            // 宽高单位均为rpx
            width: 750,
            height: 1334,
            // 背景支持色值或者网络图片背景图
            background: ‘pink‘,
            border: ‘none‘
          },
          // 坐标中心点 LngLat对象
          center: [113.9120864868165, 22.545537650869],
          // 地图范围 [LngLat, LngLat] 取点应为对角两个坐标 !!!注意坐标点位置 [右上<RT>, 左下<LB>]
          limitBounds: [[113.914489746094, 22.54744015693], [113.909683227539, 22.543635144808]],
          // 初始化地图层级
          initalZoom: 16,
          // 地图层级范围
          zooms: [16, 18],
          // 图层
          layers: [
            {
              // 图片覆盖物 坐标范围  !!!注意坐标点位置 [右上<RT>, 左下<LB>]
              limitBounds: [[113.914489746094, 22.54744015693], [113.909683227539, 22.543635144808]],
              // 覆盖物地址
              image: ‘https://weixin.xmzt.cn/static/map-bg.jpeg‘,
              // 透明度
              opacity: 1,
              // 缩放范围
              zooms: [16, 19]
            }
          ],
          // 路线
          lineStyle: {
            lineWidth: 5,
            lineColor: ‘red‘,
            lineArray: [{longitude: 112.111, latitude: 12.333}]
          },
          // 自定义Marker
          markers: [
            {
              icon: ‘/static/images/scenic/tour_voice_poi_01@2x.png‘,
              position: [113.9128,22.544674],
              style: {
                width: ‘93rpx‘,
                height: ‘105rpx‘,
                position: ‘relative‘,
                top: ‘60rpx‘
              },
              label: ‘(内测)城管大楼‘,
              labelStyle: {
                position: ‘relative‘,
                top: ‘-90rpx‘,
                left: ‘50%‘,
                transform: ‘translateX(-50%)‘,
                background: ‘#FFF‘,
                padding: ‘5rpx 10rpx‘,
                fontSize: ‘28rpx‘
              }
            },
            {
              icon: ‘/static/images/scenic/tour_voice_poi_01@2x.png‘,
              position: [113.911765,22.545397],
              style: {
                width: ‘93rpx‘,
                height: ‘105rpx‘,
                position: ‘relative‘,
                top: ‘60rpx‘
              },
              label: ‘(内测)凉亭‘,
              labelStyle: {
                position: ‘relative‘,
                top: ‘-90rpx‘,
                left: ‘50%‘,
                transform: ‘translateX(-50%)‘,
                background: ‘#FFF‘,
                padding: ‘5rpx 10rpx‘,
                fontSize: ‘28rpx‘
              }
            }
          ]
        }

整个代码其实很简单。当然也有瑕疵的地方,双指缩放时,缩放中心点问题(解决方案可以是缩放开始时便锁定当前缩放中心点,可解决。提供的代码中未解决。)

整个代码计算量都是很大的。所以性能会有所丢失。主要思路:火星坐标=>物理坐标=>画布坐标=>绘制点或者线

至于精准度问题:基本和高德地图提供的对比图是一致的,画质方面会更加清晰。

其他的便是性能问题了主要性能问题包括两个:一个是cover-view渲染较慢 造成部分东西渲染延迟  拓展性严重下降

另一个是手绘图的图片大小不宜过大,一般手机带不动。当然测试的晓龙855手机和IPhoneXR以上的就没这个问题的啦  需要适当的调节dpr即绘画质量

该方案完全适配高德地图坐标,即火星坐标系。其他坐标正常来说都是通用的。因为绘制的并不涉及投影点问题。距离的计算公式都是统一的。

暂不提供GitHub示例。没时间,有空再说。

微信小程序手绘地图实现之《Canvas》

 

微信小程序手绘地图实现之《Canvas》

上一篇:微信小程序组件——详解wx:if elif else的用法


下一篇:Ubuntu 配置 Android 开发 环境