微信小程序手绘地图实现之《Canvas》
环境:微信SDK2.9+ + uniapp (可切换直接使用.map.js不限制环境)
正题:
先创建一个地图组件
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示例。没时间,有空再说。