说在前面
还是接着上回那个活~
他们以前用百度地图实现了一个跨时间线绘制轨迹线的功能,效果如下:
之所以需要这种功能,是因为海洋上没有实际的轨迹点,只能美化处理两端点连线,否则当从小于经度180度到大于-180的两个点相连时,若不做特殊处理,会反向拉一条直线,而不是跨域太平洋拉轨迹线。处理思路主要是判断从小于经度180度到大于-180的两个点,使用二阶贝塞尔曲线进行绘制,这样可以示意从东半球跨域到西半球的轨迹(为了美观,还需要判断跨越经度超过20度时,也做贝塞尔曲线处理,这个后面说)。
开发环境
开发环境:vue+openlayers6,底图为百度地图,可根据实际情况进行坐标转换
第一步:做轨迹线跨日期判断,把对应的坐标点对存放起来
核心方法就是要将超出最大轨迹线的那部分点的经度坐标偏移系数(40075016.68也就是最大经度的两倍值),这样在绘制时,才能接着最大轨迹往右边继续绘制(实际这样在ol里会出bug,后面会说如何解决)。
/**
* 处理轨迹线,特别是跨日期线,目前只考虑从180~-180,后续应增加-180到180的处理
* @param pointsArr
*/
dealTimeLineOfPoints(pointsArr) {
if (!pointsArr || pointsArr.length < 1) {
return null
}
//返回两类数据,一类是处理后的轨迹数据,用于单点定位到轨迹上;再存一个实际绘制的轨迹点对,可能是multipolyline,一类是绘制贝塞尔曲线的起始点对(默认超过50度需要绘制曲线,看起来比较圆滑好看)
let reObject = {
dealedLine: [],
multiLine: [[]],
bezierPoints: []
}
let eastToWestNum = 0//计算从东半球到西半球的次数,即系数n,用后一个坐标的经度减去n*360,实际绘制还得回归180~-180的坐标范围,否则会出现超出坐标范围的线消失的问题
let pyNum = 40075016.68//偏移系数 360//
for (let i = 0; i < pointsArr.length - 1; i++) {
let p = pointsArr[i]
//lngX和latY保存原始的坐标数据,用于前端列表显示
// p.lngX=p.lng
// p.latY=p.lat
let pNext = pointsArr[i + 1]
let newSt = [p.lng, p.lat]//第一个点的坐标点对
let newEnd = [pNext.lng, pNext.lat]//第二个点的坐标点对
//先判断相邻两个点是否第一个在0~180,第二个一个在-180~0,若是,则这一段线不绘制,用贝塞尔单独绘制,原始坐标不做处理,这种情况下的贝塞尔第二个点存储为处理后的
let isMuli = false //经过时间线截断;
let isBez = false//需要贝塞尔曲线的情况:经过时间线截断;两个经度相差超过50
if (geosUtil.isInEast(newSt[0]) && !geosUtil.isInEast(newEnd[0]) && Math.abs(newEnd[0] - newSt[0]) > 180) {//每从东半球跨到西半球,且两者只差大于180度,就次数+1
eastToWestNum++
isMuli = true
}
//判断两个点经度是否相差20度,是的话则需要绘制曲线
let cutBez = 20
if (isMuli) {//在跨时间线的情况下不用判断是否差20度,需要直接绘制贝塞尔曲线
isBez = true
} else {//非跨时间线情况
if (Math.abs(newEnd[0] - newSt[0]) >= cutBez) {
isBez = true
}
}
//坐标转换,这里把地理坐标(WGS84)转换为投影坐标(BD09),详见另一篇文章中的转换方法
//https://blog.youkuaiyun.com/weixin_42356271/article/details/126125853?csdn_share_tail=%7B%22type%22%3A%22blog%22%2C%22rType%22%3A%22article%22%2C%22rId%22%3A%22126125853%22%2C%22source%22%3A%22weixin_42356271%22%7D
newSt = geomUtil.zc_format_coordinate(newSt[0], newSt[1])
newEnd = geomUtil.zc_format_coordinate(newEnd[0], newEnd[1])
if (isBez) {
if (isMuli) {
let newEndLon = newEnd[0] + pyNum //eastToWestNum*pyNum 由于最后都得回归180到-180,所以不用计算跨日期线的次数了//计算实际应该绘制的经度(绘制贝塞尔用)
reObject.bezierPoints.push([newSt, [newEndLon, newEnd[1]]])//跨时间线且经度相差超过50度,则第二个坐标的经度保存处理后的
} else {
reObject.bezierPoints.push([newSt, newEnd])//只是普通的绘制贝塞尔曲线,则只保存正常坐标
}
}
if (i == 0) {//第一个点添加
reObject.dealedLine.push(newSt)
if (!isBez) {//只有不绘制贝塞尔时,第一个点才加入多段线
reObject.multiLine[0].push(newSt)
reObject.multiLine[0].push(newEnd)
} else {//绘制时,将第二个点加入多段线
reObject.multiLine[0].push(newEnd)
}
} else {//不是第一个点
if (!isBez) {//不绘制贝塞尔时(正常绘制),则增加进多段线中
reObject.multiLine[reObject.multiLine.length - 1].push(newEnd)//上一段轨迹添加坐标
}
else {//如果因为跨日期线或者两点跨度太大需要弧线美化,不需要在多段线中增加此坐标
reObject.multiLine.push([newEnd])//新建下一段轨迹,且添加第二个坐标,否则会漏一个点
}
}
reObject.dealedLine.push(newEnd)
}
console.dir(reObject)
return reObject
},
/**
* 判断经度是否在东半球,也就是0-180度,是返回true,否返回false
* @param lon
*/
isInEast(lon) {
if (lon <= 180 && lon > 0) {
return true
} else {
return false
}
},
直接绘制效果
第二步:绘制贝塞尔曲线(二阶)
网上找了很多绘制贝塞尔曲线的例子,都不太理想,这里重新梳理了下代码,可以支持自定义绘制二阶贝塞尔曲线的角度以及绘制精细度(拆分段数)。
这里还增加了对跨度超过20度(可自定义)的坐标点对增加贝塞尔曲线绘制,可以美化效果。
PS:此处有个bug需要处理,即超出20037508.34的值需截断成两部分绘制(将第二个点处理,整体左移2倍经度,详见代码),否则会出现挪动时这部分轨迹(超过最大经度的那部分轨迹线)消失,这应该是openlayers的一个bug,网上有资料说将wrapX设置为false就解决了,这个不适用于当前情况,因为我们就是要左右无限拖动的
maxLon : 20037508.34,//最大经度
maxLat : 20037508.34,//最大纬度
/** 绘制贝塞尔曲线--------------------------------------------*/
ConstantMultiVector2(c, pos) {
return [c * pos[0], c * pos[1]];
},
vector2Add(a, b) {
return [a[0] + b[0], a[1] + b[1]];
},
/**
* a = > [ lng,lat]
* b = > [ lng,lat]
* n => ratio 比例,一般为50%,即0.5,此时拿的是中点位置
* 二维向量线性插值
*/
linerInperpote(a, b, n) {
let curA = this.ConstantMultiVector2(1.0 - n, a);
let curB = this.ConstantMultiVector2(n, b);
return this.vector2Add(curA, curB);
},
conHopCount: 50,//拆分段数,默认50段
/**
* 获取 当前贝塞尔曲线上的 点坐标
* @param originPos1
* @param center
* @param originPos2
* @param times
* @param hopCount 拆分段数,默认50段
* @returns {*}
*/
getCurrentEnd(originPos1, center, originPos2, times, hopCount) {
hopCount = hopCount ? hopCount : this.conHopCount
let curTimes = times / hopCount;
let a = this.ConstantMultiVector2(Math.pow(1.0 - curTimes, 2), originPos1);
let b = this.ConstantMultiVector2(2 * curTimes * (1 - curTimes), center);
let c = this.ConstantMultiVector2(Math.pow(curTimes, 2), originPos2);
return this.vector2Add(this.vector2Add(a, b), c);
},
/**
* 获取控控制点:,若未提供偏移量,则默认起点正向偏移10度。
* @param originPos1 起点
* @param originPos2 终点
* @param offset 偏移角度
*/
getControlP(originPos1, originPos2, offset) {
offset = offset ? offset : 10
offset = 2 * Math.PI / 360 * offset //计算弧度
//偏移量需要优化,可以设置根据角度(暂时设置30度夹角计算两个点之间的控制点)
let centerPos = this.linerInperpote(originPos1, originPos2, 0.5);//开始点 与结束点 的中点 的位置
let st2centerLen = calculateUtil.calculateLength(originPos1[0], originPos1[1], centerPos[0], centerPos[1])//起点到中点的距离
let st2ControlLen = st2centerLen / Math.cos(offset)//起点到控制点的距离
let shortLen = Math.abs(centerPos[1] - originPos1[1])//中点和起点的短边
let longLen = Math.abs(centerPos[0] - originPos1[0])//中点和起点的长边
let otherAngle = Math.atan(shortLen / longLen)//起止点与x轴夹角(弧度)
let sumAngle = otherAngle + offset//起点-控制点与x轴的夹角(弧度)
let sShort = st2ControlLen * Math.cos(sumAngle)//起点到控制点的短边:x轴
let sLong = st2ControlLen * Math.sin(sumAngle)//起点到控制点的长边:y轴
let controlX = originPos1[0] //控制点x
let controlY = originPos1[1]//控制点y
//这里根据第二个点与第一个点进行比对,来决定弧度向上或向下,若统一都向上,则可不进行判断,直接+
if (originPos2[0] >= originPos1[0]) {
controlX += sShort
} else {
controlX -= sShort
}
if (originPos2[1] >= originPos1[1]) {
controlY += sLong
} else {
controlY -= sLong
}
return [controlX, controlY]
},
/** --------------------------------------------- */
/**
* 贝塞尔曲线绘制
* @param bezierPoints 绘制贝塞尔的点集合,支持多段绘制,[[[1,1],[2,2]],[[3,3],[4,4]]]
* @param offsetValue 偏移角度
*/
drawBezelLine(bezierPoints, offsetValue) {
//第一步,遍历需要绘制贝塞尔曲线的起始点对
for (let i in bezierPoints) {
let startPos = bezierPoints[i][0]
let endPos = bezierPoints[i][1]
let centerPos = this.getControlP(startPos, endPos, offsetValue)
let res = [startPos]
let times = 1
while (times % geomUtil.conHopCount != 0) {
res.push(this.getCurrentEnd(startPos, centerPos, endPos, times))
times++
}
res.push(endPos)
bezierPoints[i] = res
}
//第二步,判断经过时间线的两个坐标点对,其他正常的进行分组
let mergeArr = [] //正常的坐标点对
let cutBzArr = []//跨时间线的第二段贝塞尔的坐标点对
let outBzArr = [] //全部超出时间线的部分
let outNum=0
for (let j in bezierPoints) {
let curBezierPoints = bezierPoints[j]
outNum = 0
for (let i = 0; i < curBezierPoints.length - 1; i++) {
let st = curBezierPoints[i]
if(i==0){
mergeArr.push([])//先加一个空的
mergeArr[mergeArr.length-1].push(st) //第一个先加进去
}
let end = curBezierPoints[i + 1]
let compareV = this.isCrossTimeLine(st,end)
if(compareV=="FAN"){//如果正向跨时间线
let inPoint = geosUtil.calculateIntersectionPointOfTwoLines(st,end,[this.maxLon,this.maxLat/2],[this.maxLon,-this.maxLat/2])//计算交点
let halfLat = inPoint[1]//(end[1] + st[1]) / 2//以前取纬度中点作为交点效果略差
let lastLon = end[0] - 2 * this.maxLon//将第二个点处理,整体左移2倍经度
let lastLat = end[1]//第二个点纬度存储用于新增第二截的
mergeArr[mergeArr.length-1].push([this.maxLon,halfLat])//中间点加进去
//这一段要截断,从新开始增加下一段的,正常情况不会出现——————————————————————
mergeArr.push([])
//第二段起止坐标放到这个arr里
cutBzArr.push([[-this.maxLon,halfLat],[lastLon,lastLat]])
}else if(compareV=="ZHENG"){//如果反向跨时间线
}else if(compareV=="OUT"){//两个点都超出最大lon
if(outNum==0){//第一次查到,加[]
outBzArr.push([])
outBzArr[outBzArr.length-1].push([st[0] - 2 * this.maxLon,st[1]])//加第一个点
}
outBzArr[outBzArr.length-1].push([end[0] - 2 * this.maxLon,end[1]])
outNum++
}else{//正常情况
mergeArr[mergeArr.length-1].push(end)
}
}
}
//合并arr
let newBz_1 = [...mergeArr, ...cutBzArr]
bezierPoints = [...newBz_1, ...outBzArr]
return bezierPoints
},
//判断是否经过时间线
isCrossTimeLine(bezArrSt,bezArrEnd){
if(bezArrSt[0] > this.maxLon){//第一个点超出最大经度范围
if(bezArrEnd[0] <= this.maxLon){//第二个点小于等于最大经度范围
return "ZHENG" //表示反方向跨时间线
}else{//第二个点超出最大经度范围
return "OUT"
}
}else if(bezArrSt[0] <= this.maxLon) {//第一个点在正常经度范围内(小于等于)
if (bezArrEnd[0] > this.maxLon) {//第二个点超出最大经度范围
return "FAN" //表示正方向跨时间线
}
}
return "IN" //表示不跨时间线
},
增加贝塞尔曲线效果
第三步:优化:计算两条线的交点
由于开始使用跨日期线的坐标点对与[180,90],[180,-90]的中点进行截断,效果不是很好,所以考虑还是使用两条线的交点进行截断,这样绘制出来效果就比较完美了。
/**
* 计算两条线的交点
* @param x1
* @param y1
* @param x2
* @param y2
* @param x3
* @param y3
* @param x4
* @param y4
* @returns {*[]}
*/
calculateIntersectionPointOfTwoLines([x1, y1], [x2, y2], [x3, y3], [x4, y4]) {
debugger
let a = {
x: x1,
y: y1
}
let b = {
x: x2,
y: y2
}
let c = {
x: x3,
y: y3
}
let d = {
x: x4,
y: y4
}
let rePs = this.segmentsIntr(a, b, c, d)
return [rePs.x, rePs.y]
},
/**
* 计算两条线的交点
* @param a
* @param b
* @param c
* @param d
* @returns {*}
*/
segmentsIntr(a, b, c, d) {
// 三角形abc 面积的2倍
var area_abc = (a.x - c.x) * (b.y - c.y) - (a.y - c.y) * (b.x - c.x);
// 三角形abd 面积的2倍
var area_abd = (a.x - d.x) * (b.y - d.y) - (a.y - d.y) * (b.x - d.x);
// 面积符号相同则两点在线段同侧,不相交 (对点在线段上的情况,本例当作不相交处理);
if (area_abc * area_abd >= 0) {
return false;
}
// 三角形cda 面积的2倍
var area_cda = (c.x - a.x) * (d.y - a.y) - (c.y - a.y) * (d.x - a.x);
// 三角形cdb 面积的2倍
// 注意: 这里有一个小优化.不需要再用公式计算面积,而是通过已知的三个面积加减得出.
var area_cdb = area_cda + area_abc - area_abd;
if (area_cda * area_cdb >= 0) {
return false;
}
//计算交点坐标
var t = area_cda / (area_abd - area_abc);
var dx = t * (b.x - a.x),
dy = t * (b.y - a.y);
return {x: a.x + dx, y: a.y + dy};
}
放大后看180度位置,完美跨越~