基于Konva.js的2D地图绘制原理

发表于 2024-04-23
更新于 2024-05-23
分类于 技术专栏
阅读量 300
字数统计 6788

全文比较费脑子,需要有一定背景和经验才能读懂,希望可以帮助到有这方面需求的开发同学

1、基础知识介绍

学习之前,需要先掌握一些基本的知识,包括地图的介绍以及一些数学公式;

1.1、各种坐标的介绍

目前我们公司车端使用的坐标系都是墨卡托投影坐标系,也就是UTM坐标系,因此做过云控或者monitor的人都会看到很多数据结构都有一个叫做utm的字眼,其实就是这个坐标系返回的坐标;

Universal Transverse Mercator(统一横轴墨卡托投影系统),UTM是一种投影坐标,使用基于网格的方法表示坐标,是将球面经纬度坐标经过投影算法转换成的平面坐标,即通常所说的XY坐标。

而在我们2D地图绘制的时候却是使用了GPS的坐标系,他们之间的一个分类如下:

在这里我们用的是WGS84坐标系,WGS84(World Geodetic System 1984)是为 GPS 全球定位系统建立的坐标系统,是世界上第一个统一的地心坐标系,因此也被称为大地坐标系、原始坐标系。一般通过GPS记录仪记录下来的经纬度,就是基于WGS84坐标系的数据。更多信息请参考:坐标系的介绍

1.2、三角函数的介绍

这里用到的三角函数主要是点的旋转:

根据三角函数计算,得到对应的公式如下:

如果是反向旋转的话,只需要把角度换成负的即可,再根据cos(-α)=cosα,sin(-α)=-sinα的公式,可以反向得到

1x'=xcosα+ysinα 2y'=-xsinα+ycosα

2、地图组件的介绍

目前2D地图组件库的层次结构如下:

入口文件主要是请求地图数据(包括接口请求、缓存刷新、地图尺寸变化刷新等)以及处理一些非常规数据(主要是封路数据),之后把这些数据传给地图实例组件,也就是map.tsx;

目前该组件库支持公司所有场景的地图展示,地图内部根据场景的不同,内置不同的地图元素,并支持动态扩展自定义的地图元素,地图还提供工具箱能力,包括测距、封路、绕行、缩放等操作;

3、2D地图绘制原理解析

整体2D地图的绘制分为以下环节:

  • 将标准的地图数据进行格式化处理,从utm坐标系转化成gps坐标系
  • 根据画布大小以及地图大小确定绘制的一些参数
  • 确定经纬度转化为屏幕像素点坐标的关系公式
  • 绘制各个地图元素

详细拆解如下:

以下的计算都是基于画布950*480的大小,画布不同,下面计算得到的值也不同

3.1、标准地图数据格式化处理

一般从后端给到的地图数据结构都要进行解析,转成我们地图组件需要的数据结构。

3.2、确定绘制的参数

对应的实现是在map.tsx上的useEffect里面,监听mapData的变化,从而做的一系列改变:

1const points = sections 2.map((item) => item.points) 3.reduce((total, item) => total.concat(item), []); 4 5const pointX: number[] = []; 6const pointY: number[] = []; 7points.forEach((item) => { 8pointX.push(item[0]); 9pointY.push(item[1]); 10}); 11// 计算出地图的最大和最小坐标: 12const xMax = Math.max(...pointX); 13const xMin = Math.min(...pointX); 14const yMax = Math.max(...pointY); 15const yMin = Math.min(...pointY); 16// 计算在固定画布的大小之下,需要将这些坐标点均匀放在画布内的放大比例 17// 以xx港口为例,xMin=121.99142316173753,xMax=122.02875945229853 18// yMin=29.766746639659182,yMax=29.795953482361416, 19// 也就是要将上述xDis = 0.03733629056,yDis = 0.0292068427 的数据全部绘制进去 20// 所以需要计算一个放大比例,先分别计算x轴和y轴的放大比例 21const xScale = konvaConfigRef.current.width / Math.abs(xMax - xMin); 22const yScale = konvaConfigRef.current.height / Math.abs(yMax - yMin); 23// 这里取大取小个人觉得影响不大,取小的话就保证x和y轴的数据都不至于超过画布的像素 24// 以xx港口为例子,xScale=25444.413082437022,yScale=16434.50491700329,取小 25// 的话,那么将经度放大对应到x轴坐标的时候就肯定不会超过画布的最大值 26const scaleVal = xScale < yScale ? xScale : yScale; 27// 计算以上述放大比例之后,x和y轴距离实际画布的一个误差值,很明显,以xx港口为例,y轴的误差值近似为0,但是x轴因为放大比例不是取的自己的放大比例,所以中心点的坐标距离整个画布的x轴中心点有168.19827459628397像素的误差 28const xoffset = konvaConfigRef.current.width / 2 - (Math.abs(xMax - xMin) / 2) * scaleVal; 29const yoffset = konvaConfigRef.current.height / 2 - (Math.abs(yMax - yMin) / 2) * scaleVal;

这样有了上面的计算之后,就可以得出将经纬度转变为像素坐标点的计算公式。

这里有一个点很关键,按照上面的计算关系,我们可以得知,画布和地图的大小这二者会影响到地图上元素展示的大小,而这个影响值就是上述计算出来的scaleVal。同样两个经纬度的点(也就是其距离是固定的),但是因为scaleVal的值不同,绘制到地图上的像素距离是不同的,而且scaleVal越大,说明同样的画布下,绘制的像素会越大。这里的值就是我们说的比例尺

3.3、经纬度转化为屏幕像素点坐标的关系公式

1 const transformGcjPointToPxPoint = useCallback(([lng, lat]: number[]) => { 2 const { xMin, yMax, scale, xoffset, yoffset } = baseRef.current; 3 const { x, y } = konvaConfigRef.current; 4 5 // 这里的经纬度转化为像素的计算其实需要结合上述地图的方位来设计的; 6 // 首先讲解的是前面一部分: 7 // (lng - xMin)* scale 其实等价于 ((lng - xMin) * height)/(yMax - yMin) 8 // 以上述xx港口为例子,所以其实计算原理就是:计算经度在整个地图经度范围内的百分比位置换算到画布0-950中的某个像素点,同理纬度也是一样的道理 9 // 再来说说为啥是lng-xMin,而纬度是yMax-lat,如下图展示 10 // 而后面加的offset - width / 2是为了将地图进行整体平移,平移至窗口的中心点位置 11 return [((lng - xMin) * scale + xoffset - x) * xSkew, (yMax - lat) * scale + yoffset - y]; 12 }, []); 13 14// 反之将归一化的点转换为经纬度也是一样的,逆向求解即可: 15const transformPxPointToGcjPoint = useCallback(([x, y]: number[]): number[] => { 16 const { xMin, yMax, scale, xoffset, yoffset } = baseRef.current; 17 18 x = (x / xSkew - xoffset + konvaConfigRef.current.x) / scale + xMin; 19 y = yMax - (y - yoffset + konvaConfigRef.current.y) / scale; 20 return [x, y]; 21 }, []);

根据小学数学知识可以知道已知放大比例,那么某个坐标放大之后对应的像素点肯定是一个偏差值之后计算的,因为这个放大是一个线性过程;重点是y值的计算:(yMax - lat),这个其实是因为canvas的坐标系是下面图例这样的,但是纬度却是值越大,就越靠上:

因此将其斜率进行反转,就有了(yMax - lat)的计算,计算出来的结果也是非常符合地图的一个朝向:

实际绘制出来的效果

而后面转换坐标的操作就是一个平移的过程,为了将所有点平移到视窗内做的一个偏移,之后看到的xSkew就是一个扭转的过程,这个是经验值,可以刻意将整体地图进行一定扭曲,保证箱区的平整性;(个人觉得这个是不需要的)

3.4、绘制所有地图元素

有了上述的转换公式之后,就可以将所有点的坐标统一转化,这个时候还不需要考虑旋转和缩放的影响,并且即使有选择和缩放,那么这个操作也是仅仅作用在Stage上,所有给地图元素的坐标都是归一化的坐标。

归一化指的是不受任何影响,做的最初始的一个动作

1<Stage 2 ref={stageRef} 3 height={konvaConfigRef.current.mapHeight} 4 width={konvaConfigRef.current.mapWidth} 5 rotation={konvaConfigRef.current.rotation} 6 name="stage" 7 draggable 8 onMouseDown={handleStageClick} 9 onDragEnd={handleStageDragEnd} 10 > 11 {/* 这里绘制各种元素*/} 12 </Stage>

注意各个图层元素的层级关系,后渲染的永远回去覆盖前面渲染的。另外注意的一点是,所有的图层元素除了道路和路口之外,都需要考虑进行选择旋转,因为地图本身绘制出来就是斜的,而图片、文字则是平行的,所以需要加个反向旋转的角度进行匹配:

3.5、地图加上旋转和缩放

原始地图肯定会因为使用习惯进行各种旋转和缩放的,一个点经过旋转缩放后得到的新的点肯定和之前的不一样的,那么怎么得到这个计算关系呢?
这个时候我们就得用上第一节说到的三角函数知识了,众所周知一个点旋转后(逆时针)得到另外一个点的公式就是:

因此我们可以写出如下的转化公式:

1 /** 2 * 反归一化,将归一化的点结合旋转、缩放等因素转化为实际的像素点 3 * @param param0 4 * @returns 5 */ 6 const reverseNormalizationPoint = ({ x, y }: { x: number; y: number }): number[] => { 7 const stage = stageRef.current; 8 if (!stage) { 9 return [0, 0]; 10 } 11 const oldScale = stage.scaleX(); 12 // 先旋转 13 const mousePointTo = rotatePoint({ x, y }, konvaConfigRef.current.rotation); 14 // 再缩放,这里需要加上画布所处的位置是因为啥? 15 // 是因为画布的位置并不一定在canvas的原点上,而所有的坐标是需要基于canvas坐标系来的 16 const res = [ 17 mousePointTo[0] * oldScale + Number(stage.x()), 18 mousePointTo[1] * oldScale + Number(stage.y()), 19 ]; 20 return res; 21 };

反之也是,将一个被缩放并旋转过的点进行归一化,其逆向计算公式则是:

1const normalizationPoint = ({ x, y }: { x: number; y: number }) => { 2 const stage = stageRef.current; 3 if (!stage) { 4 return [0, 0]; 5 } 6 const oldScale = stage.scaleX(); 7 // 先缩放还原 8 const mousePointTo = [(x - stage.x()) / oldScale, (y - stage.y()) / oldScale]; 9 // 再顺时针旋转还原 10 const rotatePoints = reverseRotatePoint( 11 { x: mousePointTo[0], y: mousePointTo[1] }, 12 konvaConfigRef.current.rotation, 13 ); 14 return [rotatePoints.x, rotatePoints.y]; 15 };

3、一些技术难题的解决

  • 我们解决了在任意的时刻可以让某个坐标点位置居中的难题,其计算原理如下:

  • 我们还解决了仅根据实际物体的尺寸来实现地图上元素在不同屏幕尺寸的偏差精度问题

  • 解决了地图的整体性能问题,这个后面再搞篇文章分享;

公众号关注一波~

微信公众号

关于评论和留言

如果对本文 基于Konva.js的2D地图绘制原理 的内容有疑问,请在下面的评论系统中留言,谢谢。

网站源码:linxiaowu66 · 豆米的博客

Follow:linxiaowu66 · Github