百度地图历险记之LuShu路书全解

百度地图历险记之LuShu路书全解

项目简介:

接了个双创项目的前端开发的活,主要用地图来展示一些信息,比如时间地点事件什么的。使用了Antd+react解决方案。

欢迎点个star支持下:思源siyuan

React-BMapGL文档 (baidu.com)有React中使用百度地图API的封装,可以直接使用,但很可惜事实上只封装了一小部分,很多功能并没有直接封装,但是是可以通过调用JspopularGL来实现的,这样我们的操作就更广一些了。

我们如果想要使用jspopularGL,就需要获得到map对象本身。根据文档,我们可以通过ref来实现:

获取map实例

如果你在业务中需要操作map对象,需要BMapGL.Map实例的话,可以通过<Map>组件实例的map属性访问到它。

<Map ref={ref => {this.map = ref.map}} />

但是实测,因为异步加载的原因,有时候会出现undefined或者null问题,所以我们可以通过再封装一层的方式:

export default class MyMap extends React.Component {
	constructor(props) {
		super(props);
     ...
     this.created = flase;
     ...
 }
 _initMap(){
     ...
     this.map = this.mapRef.map;
     ...
 }
 componentDidMount() {
		if(!this.created) {
			this._initMap();
         this.created = true;
     }
 }

 render() {
		...
     return(
     	<Map ref={ref => {this.mapRef = ref}}>
         	...
         </Map>
     )
 }
}

来解决这个问题,这样相当于把ref.map的获取放到确保组件已经生成后,就不会产生空问题。同时也可以把Map抽象出成一个React.Components,在上层直接使用,简单方便。

如果你的上层也需要map对象本身,你可以使用

render() {
	const content=
        <MyMap
          className="map"
          ref={(ref) => {this.map = ref}}
        />
}

来获取。

LuShu的引入

为了美观 和狂拽酷炫 ,我们使用了React-BMapGL已经封装好的Arc 2D弧线来展示路径,效果还不错:
在这里插入图片描述

  • (数据是随机mock的,所以地名和坐标都是随机的,不代表任何现实真实含义)
  • 每个点的小图标是自行实现的,不展开叙述,后面博文可能会写

完成Arc部分之后,感觉可以加些更酷的元素进去,于是在open | 百度地图API SDK (baidu.com)里面找到了这个东西:
在这里插入图片描述
名字叫路书,看起来很不错,是个动画,可以沿着路径走,并且可以附上HTML元素,在飞机上方展示,于是决定引入。

从示例中找到源码:

https://bj.bcebos.com/v1/mapopen/github/BMapGLLib/Lushu/src/Lushu.min.js

首先遇到的第一个问题就是,不知道怎么用。第一次遇到这样的结构:

(function (){
	//function body
})()

实际上就是说定义了一个函数并且直接执行之。只需要把它下载下来放到工作文件夹中,重命名一下,然后在我们需要使用的js文件中添加:

import "./Lushu"

就可以了,然后就会发现一堆报错。

在这里插入图片描述
实际上报错的原因大概只有

  • 找不到BMapGL.*

    针对这类,直接改成window.BMapGL.*就可以了

  • 缺少某个变量

    这个文件由于没有直接访问到BMapGL,所以在这些变量使用前定义一下就好,例如:

    在这里插入图片描述

    这两个变量前面加上const或者var就可以了,声明一下。

  • defaultIcon没有

    我们可以手动定义一个,比如像示例里一样用base64编码的图片:

          var defaultIcon = "data:image/png;base64,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";
          //如果不是默认实例,则使用默认的icon
          if (!(this._opts.icon instanceof window.BMapGL.Icon)) {
            this._opts.icon = defaultIcon;
          }
    

    这么长的这一段就是用base64编码的一个图片,当然也可以找个png转base64编码的工具自己魔改。

目前为止终于能用了。由于一开始比较懒并不想去分析源码,就选择去搜索了现有的关于lushu的使用方法,发现似乎lushu有很多版本。例如上文提到过的版本,和地图JS API示例 | 百度地图开放平台 (baidu.com)这里的大地线路书等等。目前为止功能较为全面的是这里的大地线路书中的版本:

api.map.baidu.com/library/LuShu/gl/src/LuShu_min.js

不知道是什么原因。

这个版本在基础功能之上实现了geodesic,autoCenter等功能,比较实用。

部署使用之后发现问题:lushu的运动轨迹和Arc的轨迹并不重合,于是决定开始自行魔改。

部分源码解读

首先明确目标:让lushu的运动轨迹和Arc的轨迹重合。所以大概思路是

  1. 明确Arc是如何绘制的
  2. 明确lushu是如何运动的
  3. 修改源码,使得lushu按照Arc的轨迹运动

首先来看Arc源码(node_modules/react-bmapgl/dist/Custom/Arc.js)

在这里插入图片描述
重点是if(this.props.data)中的部分。可以看到Arc的轨迹实际上是通过构造OdCurve,再使用OdCurve.getPoints()方法获得轨迹中点的坐标的。

OdCurve

通过传入2个或2个以上的坐标点,来依次生成od曲线坐标集。
该曲线为2D弯曲方式,且不同于大地曲线,大地曲是根据球面最短距离来计算的,距离太近的2个点基本不会弯曲,而这个Od曲线的生成算法不同,即使很短的距离也会弯曲。

OdCurve提供了两个方法:

getPoints

描述:getPoints({number}|{undefined})

解释:获取生成的Od曲线坐标集,传入的字段为曲线的分段数,默认值是20

setOptions

描述:setOptions({Object}options)

解释:修改坐标数组等属性

看到getPoints方法,我直呼牛B,这开发者是知道使用者想要什么的。

接下来再来看看lushu是如何运动的。

首先直接看构造方法:

  var LuShu = (BMapGLLib.LuShu = function (map, path, opts) {
    if (!path || path.length < 1) {
      return;
    }
    this._map = map;
    if (opts["geodesic"]) {
      this._path = getGeodesicPath(path);
    } else {
      this._path = path;
    }
    this.i = 0;
    this._setTimeoutQuene = [];
    this._opts = { icon: null, speed: 400, defaultContent: "" };
    if (!opts["landmarkPois"]) {
      opts["landmarkPois"] = [];
    }
    this._setOptions(opts);
    this._rotation = 0;
    if (!(this._opts.icon instanceof BMapGL.Icon)) {
      this._opts.icon = defaultIcon;
    }
  });

大致做了以下几件事:

  1. 设置好path,也就是this._path,具体用处在后面。
  2. 设置了一些字段,比如this.i
  3. 设置了opts

再来看我们让lushu开始时调用的lushu.start()

  LuShu.prototype.start = function () {
    var me = this,
      len = me._path.length;
    if (me.i && me.i < len - 1) {
      if (!me._fromPause) {
        return;
      } else {
        if (!me._fromStop) {
          me._moveNext(++me.i);
        }
      }
    } else {
      me._addMarker();
      me._timeoutFlag = setTimeout(function () {
        me._addInfoWin();
        if (me._opts.defaultContent == "") {
          me.hideInfoWindow();
        }
        me._moveNext(me.i);
      }, 400);
    }
    this._fromPause = false;
    this._fromStop = false;
  };

判断了一下从什么状态开始start的,我们直接看最后一个else里的内容:

首先把图标(marker)添加进来,然后设置了一个setTimeout,具体内容是把infowindow添加进来,然后执行了me._moveNext(me.i),这个函数就是所有的关键点了。我们先明确me.i是怎么来的,事实上它就是this.i,也就是在构造函数中设置的一个字段,我们进到_moveNext中来看其含义:

    _moveNext: function (index) {
      var me = this;
      if (index < this._path.length - 1) {
        me._move(me._path[index], me._path[index + 1], me._tween.linear);
      }
    },

到这里就很明显了,在构造函数中构造的path存放的是各个点(事实上,它是一个[{lng, lat}]类型的数组),而i则是用来标注当前已经走到第几个点。我们进到_move中去看到底是如何运动的。

    _move: function (initPos, targetPos, effect) {
      var me = this,
        currentCount = 0,
        timer = 10,
        step = this._opts.speed / (1000 / timer),
        init_pos = BMapGL.Projection.convertLL2MC(initPos),
        target_pos = BMapGL.Projection.convertLL2MC(targetPos);
      init_pos = new BMapGL.Pixel(init_pos.lng, init_pos.lat);
      target_pos = new BMapGL.Pixel(target_pos.lng, target_pos.lat);
      var mcDis = me._getDistance(init_pos, target_pos);
      var direction = null;
      if (mcDis > 30037726) {
        if (target_pos.x < init_pos.x) {
          target_pos.x += WORLD_SIZE_MC;
          direction = "right";
        } else {
          target_pos.x -= WORLD_SIZE_MC;
          direction = "left";
        }
      }
      var count = Math.round(me._getDistance(init_pos, target_pos) / step);
      if (count < 1) {
        me._moveNext(++me.i);
        return;
      }
      me._intervalFlag = setInterval(function () {
        if (currentCount >= count) {
          clearInterval(me._intervalFlag);
          if (me.i > me._path.length) {
            return;
          }
          me._moveNext(++me.i);
        } else {
          currentCount++;
          var x = effect(init_pos.x, target_pos.x, currentCount, count),
            y = effect(init_pos.y, target_pos.y, currentCount, count),
            pos = BMapGL.Projection.convertMC2LL(new BMapGL.Point(x, y));
          if (pos.lng > 180) {
            pos.lng = pos.lng - 360;
          }
          if (pos.lng < -180) {
            pos.lng = pos.lng + 360;
          }
          if (currentCount == 1) {
            var proPos = null;
            if (me.i - 1 >= 0) {
              proPos = me._path[me.i - 1];
            }
            if (me._opts.enableRotation == true) {
              me.setRotation(proPos, initPos, targetPos, direction);
            }
            if (me._opts.autoView) {
              if (!me._map.getBounds().containsPoint(pos)) {
                me._map.setCenter(pos);
              }
            }
          }
          if (me._opts.autoCenter) {
            me._map.setCenter(pos, { noAnimation: true });
          }
          me._marker.setPosition(pos);
          me._setInfoWin(pos);
        }
      }, timer);
    },

_move的源码比较长,其实总共只分为两段:

  1. 设置一些变量,比如:

    • currentCount
    • timer
    • step
    • count

    然后通过计算获得一些值,比如pos相关的部分。

  2. 设置运动,也就是setInterval里面的部分。

设置的这些变量似乎有些让人摸不着头脑,我们来分析一下。首先突破口是timer这个量,因为它在setInterval中被直接用到了,含义比较明确:代表着每次执行的间隔时间。那么这段代码的终止条件是什么呢,我们来看:

        if (currentCount >= count) {
          clearInterval(me._intervalFlag);
          if (me.i > me._path.length) {
            return;
          }
          me._moveNext(++me.i);
        } else {
            currentCount++;
            ...
        }

可以看到,事实上是每次执行都会使currentCount自增,直到等于count,我们再回过头来看count的定义:

    step = this._opts.speed / (1000 / timer),  
	var count = Math.round(me._getDistance(init_pos, target_pos) / step);

我们来列式子算一下:
step = speed 1000 × timer \text{step} = \frac{\text{speed}}{1000}\times \text{timer} step=1000speed×timer
由于timer是常量,我们可以写成:
step ∝ speed \text{step} \propto \text{speed} stepspeed
那么
count = distance step \text{count}=\frac{\text{distance}}{\text{step}} count=stepdistance
这是什么,这可不就是
t = s v t=\frac{s}{v} t=vs
嘛,所以说可以简单理解为:

  • count表示完成这段运动一共需要多少"帧";
  • currentCount表示现在运动到第几"帧"了;
  • timer表示运动一帧所需要的时间(ms);
  • step只是一个中间量;

继续往下分析:

        } else {
          currentCount++;
          var x = effect(init_pos.x, target_pos.x, currentCount, count),
            y = effect(init_pos.y, target_pos.y, currentCount, count),
            pos = BMapGL.Projection.convertMC2LL(new BMapGL.Point(x, y));
			...
          me._marker.setPosition(pos);
          me._setInfoWin(pos);
        }
      }, timer);
    },

中间省略的是与运动直接关系不太大的(关于rotation后面会讲)部分,可以看到其实和我们猜测的一样,每次执行都通过effect函数来获得下一帧的坐标,然后调用setPosition()来修改位置,这样就可以做出动效来了。

有了这些之后,我们简单的思路就是:既然Arc使用了OdCurve,我们只需要在lushu中也得到同样的点路径,然后通过修改effect方法来在每帧中获取对应的点坐标即可。

1. 构造OdCurve

首先引入mapvgl:

var mapvgl_1 = require("mapvgl");

在lushu的构造方法中完全仿照Arc构造一个点列出来即可:

	...
    const MAX_FRAME = 300;   
	if (opts["geodesic"]) {
      this._path = getGeodesicPath(path);
    }
    else if (opts["odCurve"]){  // 是否使用Odcurve
      var lineData = [];
      var curve = new mapvgl_1.OdCurve();
      for(var i = 0 ; i < path.length-1 ; i++){
        var start = path[i];
        var end = path[i+1];
        curve.setOptions({
          points: [start, end]
        });
        var curveModelData = curve.getPoints(MAX_FRAME-1); //最细
        lineData.push(curveModelData)
      }
      this.lineData = lineData;
      this._path = path;
    }
    else {
      this._path = path;
    }
	...

这里有个小trick:我使用了MAX_FRAME-1作为getPoints的参数,来获取到长度为MAX_FRAME的点列,这么做是因为我想要让lushu在每段Arc的运动时长相等,而不是速度相等,避免非常短和非常长的Arc在同一个展示中,导致lushu非常鸡肋。

因为lushu是通过currentCount来控制的,如果要每段都定时的话其实非常简单,只需要固定下来count就可以了。这样的话如果我需要更改这个时长,也可以通过设置count来实现。

MAX_FRAME其实是为了方便其它时长的情况方便地获取到点列,直接通过(0, MAX_FRAME)到(0, count)的映射就可以获得到对应的点,而不需要每针对一个count就重新获取一个点列。

2. 修改effect

事实上,effect函数是一个回调函数,它在_moveNext中被传入:

    _moveNext: function (index) {
      var me = this;
      if (index < this._path.length - 1) {
        me._move(me._path[index], me._path[index + 1], me._tween.linear);
          // me._tween.linear
      }
    },

我们找到linear

    _tween: {
      linear: function (initPos, targetPos, currentCount, count) {
        var b = initPos;
        var c = targetPos - initPos;
        var t = currentCount;
        var d = count;
        return (c * t) / d + b;
      }
    },

emmmm,也许当时开发的时候是想过拓展多种方式的,只是没实现留了个接口而已。正好我们也用得上,只是需要魔改一下:

    _tween: {
      linear: function (initPos, targetPos, currentCount, count) {
        ...
      },
      OdCurve: function (currentCount, count, lineData, i) {
        var lineDataArrayIndex = Math.round(MAX_FRAME*currentCount/count)
        return lineData[i][lineDataArrayIndex>=MAX_FRAME?MAX_FRAME-1:lineDataArrayIndex]
      }
    },

linear我这里就直接弃用了,所以参数也重新写了,这些参数的含义都已经解释过了,函数体本身也只做了一件非常简单的事:求出对应的映射点,然后把该点直接返回。

当然在effect的调用处我们也需要小改一下(_move的setInterval里):

        } else {
          currentCount++; // 下一帧
          // var x = effect(init_pos.x, target_pos.x, currentCount, me.speed, "x"),
          //   y = effect(init_pos.y, target_pos.y, currentCount, me.speed, "y"),
          var nextPoint = effect(currentCount, me.speed, me.lineData, i);
          var x = nextPoint[0],
            y = nextPoint[1],
            pos = window.BMapGL.Projection.convertMC2LL(new window.BMapGL.Point(x, y));
            ...

就完成了。此时会发现,确实按照轨迹运动了,但是旋转非常鬼畜,令人匪夷所思。我们再来看关于Rotation的部分:

            if (me._opts.enableRotation == true) {
              me.setRotation(proPos, initPos, targetPos, direction);
            }

这里给setRotation传进去了四个参数,意义比较明确(proPos应该是prePos打错了,但是问题也不大,反正都是proPos不影响运行,而且实际上这个变量都没被用过,也不知道什么原因),我们直接来看setRotation:

    setRotation: function (prePos, curPos, targetPos, direction) {
      var me = this;
      var deg = 0;
      curPos = me._map.pointToPixel(curPos);
      targetPos = me._map.pointToPixel(targetPos);
      if (targetPos.x != curPos.x) {
        var tan = (targetPos.y - curPos.y) / (targetPos.x - curPos.x),
          atan = Math.atan(tan);
        deg = (atan * 360) / (2 * Math.PI);
        if ((!direction && targetPos.x < curPos.x) || direction === "left") {
          deg = -deg + 90 + 90;
        } else {
          deg = -deg;
        }
        me._marker.setRotation(-deg);
      } else {
        var disy = targetPos.y - curPos.y;
        var bias = 0;
        if (disy > 0) {
          bias = -1;
        } else {
          bias = 1;
        }
        me._marker.setRotation(-bias * 90);
      }
      return;
    },

其实也很好理解,看到tan和atan大概就明白是直接把方向改成两个点的连线方向。但是为甚么我们使用会出问题呢,原因是传入的是这段线的起始点和终点两个点,而不是我们魔改过后的路程点列中的每个点,所以只需要在_move开始的时候设置一个新的字段currentPos

    _move: function (initPos, targetPos, effect, i) {
      var me = this,
        currentCount = 0,
        currentPos = initPos,
        ...

然后再把传入的参数改成

          if (me._opts.enableRotation == true) {
            me.setRotation(prePos, currentPos, pos, direction);
            currentPos = pos;
          }

就可以了。

By JSYRD

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值