google map + d3、AMap + echarts分别实现数据可视化中的飞线图(迁徙图)

3,793 阅读14分钟

首先肯定是给出demo啦: 演示demo

直接到左侧选择框中选择View taxi flow里面随便选个日期

总体介绍

最近由于工作室项目需要做一个数据可视化平台,这个平台最终是交由国外人使用的。而国内的高德地图在国外是访问很慢,所以只能使用Google map进行实现(尝试过用AMap去实现,后面改回来) 回归主题:今天要讲的是迁徙图分别用两个平台进行实现

  • AMap + eCharts.js 实现迁徙图
  • Google map + D3.js 实现迁徙图

首先先来谈一下为什么都是在map上面进行数据可视化,但是实现迁徙图的技术不同(一个用eCharts、一个用d3)?!先来讲一下高德地图(AMap)和谷歌地图实现的技术区别吧(Google map)。

在这里插入图片描述
上面是高德地图,高德地图的实现是基于canvas进行实现的,显示效果都在这个画板上显示。接下来我们看一下谷歌地图是怎么实现的?
在这里插入图片描述

可以发现谷歌地图是由一张张图片拼接而成的,两者之间谁优谁劣不说,但是实现方式不同。正由于二者实现方式不同,所以飞线图实现方式也会有不同。

在github上面仅有开发echarts-amap中间插件(当然还有百度地图的echarts-bmap),并没有一个项目作为echarts和google map的中间件。这个项目给我的时间仅仅只有5天,所以不可能也没有能力去写这个插件。所以只能另外找资料实现google map + d3.js 飞线。


一、用echarts + amap 实现飞线,首先看一下效果图

在这里插入图片描述
这里有1000+条飞线数据,这是我随机找广州景区互相生成的假数据。因为既然今天讲到了两种技术实现飞线,那么顺带展示一下两个技术的性能区别,以1000条为例子。这个是怎么实现的呢?!我大概分成以下几步来进行分析:

  • 引入echarts-amap插件,如果是非vue框架的,去找一下echarts-amap插件(我没有用,所以无法提供)。如果是vue框架的话,npm i echarts-amap --save-dev进行安装,然后在项目中require('eharts-amap')就行了。
  • 找到飞线所需的echarts配置
  • 初始化echarts,然后进行设置series,就和普通的echarts图标的流程一样,以下我给出例子。

下面是整个流程需要的代码,拿走就可以了。

let mychart = this.$echarts.init($('#' + id)[0]), // 初始化echarts到目标块中
	mychart.serOption({
     amap: {
        zoom: 10,
        zooms: [3, 20],
        mapStyle: 'amap://styles/darkblue', //地图主题
        center: [113.25, 23.1], //中心点
        lang: 'en',
        resizeEnable: true
      },
      animation: false,
      series: []
	});
// 以下对象的一个例子
FlyLineOption: {
	name: 'name',
	lineColor: '#fff',
	fromName: 'fromName',
	toName: 'toName',
	fromLngLat: [123, 23],
	toLngLat: [123, 23],
	value: 1,
	symbolColor: '#000'
}

// 以下是echartsoption配置对象
{
      name: FlyLineOption.name,
      coordinateSystem: "amap",
      type: "lines",
      zlevel: 1,
      effect: {
        show: true,
        period: 6,
        trailLength: 0.7,
        color: "#fff",
        symbolSize: 3
      },
      lineStyle: {
        normal: {
          color: FlyLineOption.lineColor,
          width: 0,
          curveness: 0.2
        }
      },
      data: [
        {
          fromName: FlyLineOption.fromName,
          toName: FlyLineOption.toName,
          coords: [FlyLineOption.fromLngLat, FlyLineOption.toLngLat],
          value: FlyLineOption.value
        }
      ]
    },
    {
      name: FlyLineOption.name,
      coordinateSystem: "amap",
      type: "lines",
      zlevel: 2,
      symbol: ["none", "arrow"],
      symbolSize: 10,
      lineStyle: {
        normal: {
          color: FlyLineOption.lineColor,
          width: 1,
          opacity: 0.6,
          curveness: 0.2
        }
      },
      data: [
        {
          fromName: FlyLineOption.fromName,
          toName: FlyLineOption.toName,
          coords: [FlyLineOption.fromLngLat, FlyLineOption.toLngLat],
          value: FlyLineOption.value
        }
      ]
    },
    {
      name: FlyLineOption.name,
      type: "effectScatter",
      coordinateSystem: "amap",
      zlevel: 2,
      rippleEffect: {
        brushType: "stroke"
      },
      label: {
        normal: {
          show: true,
          position: "bottom",
          formatter: "{b}"
        }
      },
      itemStyle: {
        normal: {
          color: FlyLineOption.symbolColor
        }
      },
      data: [
        {
          name: FlyLineOption.toName,
          value: [...FlyLineOption.toLngLat, FlyLineOption.value]
        }
      ]
    }

这样即可绘制飞线图。echarts这个插件其实蛮好用的,很多图都有,也比较齐全。它里面的飞线是二次贝塞尔曲线,会自动帮你绘制这条贝塞尔曲线。 但是换成google map并不会像国内的地图有这么好的生态了。可能国外会有一些插件去支持它,但是目前来看我是找不到。那么我是怎么去实现的呢?

二、Google map + D3.js 实现飞线

先讲一下总体的思路:

  1. 添加谷歌地图图层,进行绘制svg标签
  2. 利用d3在谷歌地图图层进行绘制svg图
  3. 首先将gps化为谷歌坐标,求出两点之间的贝塞尔曲线(需要自己寻找控制点),并且绘制出来(利用path标签自带的贝塞尔曲线绘制方法)

Google map 初始化

首先,你要有一个Google map 的ID,这个百度查询如何使用,然后最好是引用国内镜像,毕竟VPN来直接访问还是有点慢。

<script src="http://ditu.google.cn/maps/api/js?&key=&language=en"></script>

接来下进行地图的初始化,同样上代码

let map = new google.maps.Map(document.getElementById(this.id), {
    zoom: 8,
    center: {
    lat: 23.1,
    lng: 113.25
  },
  disableDefaultUI: true,               //取消默认的试图
  navigationControl: false,              //true表示显示控件
  mapTypeControl: false,                //false表示不显示控件
  scaleControl: false
 });

Google map 渲染技术简单介绍

初始化完毕后,我先给大家简单介绍一下google map api提供地图的技术吧。

  1. google map如何渲染? google map分成20左右的zoom层数,即放大层数,每个层数都有固定的地图的图片,将这些地图的图片进行拼装,就会出现整个世界地图的模型。
    在这里插入图片描述
    还是老样子直接上图,在进行渲染、拖拽、缩放的时候,系统会识别当前显示的区域,进行请求显示区域所需要的图片,请求完毕后进行渲染。
  2. 如何在google map 上添加自定义的图层呢? 在以canvas为基础的地图中,我们可以用两个canvas重叠后来进行渲染,即地图canvas在下面,echarts的canvas在上面,从而实现飞线效果。
    但是google map并不行,它不是这种技术实现,但是它也可以进行添加自定义图层,创建一个对象,new google.maps.OverlayView();这个对象返回一个创建的图层。它有add、draw两个属性比较重要,在本项目中会用到。
    特别地,在google map上绘画东西是需要坐标的,即横坐标和纵坐标进行绘制。这个正是google map渲染时候的技术决定的(很多图片组成,图片间是紧挨着的,这个是通过创建元素构成的。也就是需要有一个特别大的容器装下这些图片。绘制的时候只能用坐标来进行绘制)。

Google map 图层的建立

那么下面就来给大家手把手教大家如何建立图层。google map官网有一个例子进行比较正规方法的,它是通过新建一个OverlayView对象实例,对实例对象的原型进行修改add、draw方法,然后以这个实例对象为原型,进行原型链继承。那个方法太麻烦了,我这边就直接来个简单的。

let overlay = new google.maps.OverlayView();
overlay.onAdd = () => {
	// 这个函数式图层加载完毕后就执行一遍时候的事件监听函数(可以这么理解)
}
overlay.draw = () => {
	// 这个是地图每一次绘画完毕后进行的事件监听执行函数(绘画地图包括左右移动和缩放)
}

现在大家已经会进行图层的建立了,那么如何在图层上进行绘制呢?首先要得到绘制的坐标。google map有一个方法将经纬度转为地图做标。

let project = overlay.getProjection();
let latLng = new google.maps.LatLng(23, 123)
let axios = projection.fromLatLngToDivPixel(latLng);
axios.x, axios.y //这两个属性就是x值和y值

上面得到的坐标是全局绝对坐标,可能会很大。但是有显示的图层只有在屏幕范围内,所以要进行坐标的转换。如何理解呢,我画了以下的图。

在这里插入图片描述
我们需要将目标点坐标减去视图层的左上角的坐标,就能得到相对坐标了。

前面的代码已经将想要描述的点通过经纬度转为坐标了,那么现在就要进行绘制了,首先要获取overlay定义的图层,这个图层在overlay添加完毕后可以使用,代码如下:

overlay.onAdd = () => {
	let layer = overlay.getPanes().overlayLayer;  // 获取绘制容器
	// 然后通过appendChild进行绘制,使用绝对定位,设置left和right进行绘制,这样就可以了。
	// 这个是涉及到d3的一些操作,可以去了解以下d3的操作。
	
	// 下面是我的一些代码,可以拿去当做参考
	let layer = d3.select(overlay.getPanes().overlayLayer).append('svg')
	              .attr('class', 'fly-layer');  // 创建svg标签
	
	 let defs = layer.append('defs');	// 添加defs属性列表
	 defs.append('marker')				// 添加marker标签(svg标签的)这个是添加箭头的样式,因为svg标签只需要一个,所以在创建svg标签的时候就创建了
	   .attr('id', 'markerArrow')
	   .attr('markerWidth', '13')
	   .attr('markerHeight', '13')
	   .attr('refX', '2')
	   .attr('refY', '6')
	   .attr('orient', 'auto')
	   .append('path')
	   .attr('d', 'M2,2 L2,11 L10,6 L2,2')
	   .attr('fill', '#FF0000');

		// 下面是对data进行批量添加数据,此时并没有绘制path路径,而是添加flyLine类名而已,这个是方便后面进行获取容器
	   layer.selectAll('.flyLine').data(data, (d) => d.id).enter().append('path').attr('class', 'flyLines')
	   let projection = overlay.getProjection();
}

d3可以去它的官网查看文档怎么使用
d3js.org/
但是,你想看懂d3的操作,首先要知道svg矢量图是什么,怎么用的。我曾经做过一个用svg矢量图实现的流动效果的网页作为我们团队的招新网:
http://47.102.136.151:4000/dist/index.html
如果弄懂了里面svg图的变化,基本上是可以上手任何svg矢量图了。
把话题拉回来了,上面是获取绘制图层块并且在里面进行添加svg标签,并且添加一个通用的箭头效果。那么接下来来谈谈这个overlay图层怎么添加draw方法。

// draw方法是地图在缩放或者鼠标拖动的时候会触发的事件,所以在短时间会触发很多次,这个要注意到
overlay.draw = () => {
	let bounds = this.map.getBounds();
    let ne = bounds.getNorthEast(),  // 获取当前显示区域右上角的对象(可通过访问获取右上角坐标)
         sw = bounds.getSouthWest(),	// 获取当前显示区域左下角的对象
         center = bounds.getCenter();		// 获取中心点坐标
    layer.selectAll('.flyLines')
              .data(data)   // 进行更新数据
              .each(transform)  // 对每个数据渲染的对象进行修改,将transform的返回值作为这个each的参数
     // 这里为了方便我把下面两个函数就直接贴在这里
     // 这个函数计算出两个坐标点贝塞尔曲线控制点相对于两个坐标点中点的位置,isContract是为了让绘制结果更好看
     function getCosFromTan(d1, d2, isContract) {
          let value, rad;
          if (d2.x == d1.x) {
            value = 0;
            rad = Math.PI / 2;
          } else {
            value = (d2.y - d1.y) / (d2.x - d1.x),
            rad = Math.atan(Math.abs(value));
          }
          
          let length = Math.pow(Math.pow(d2.y - d1.y, 2) + Math.pow(d2.x - d1.x, 2), 1 / 2),
              paramX, paramY;

          if (value < 0) {
            paramX = 1;
            paramY = 1;
          } else {
            paramX = 1;
            paramY = -1;
          }

          return isContract ? {
            x: paramX * length * Math.sin(rad),
            y: paramY * length * Math.cos(rad)
          } : {
            x: -(paramX * length * Math.sin(rad)),
            y: -(paramY * length * Math.cos(rad))
          }
        }

        function transform(flyObj) {
          // 将原生的gps坐标改为谷歌地图坐标
          let fromLngLat = new google.maps.LatLng(flyObj.fromLnglat[1], flyObj.fromLnglat[0]);
          let toLngLat = new google.maps.LatLng(flyObj.toLnglat[1], flyObj.toLnglat[0]);
          // 将右上角的坐标转为谷歌地图坐标
          let neLngLat = new google.maps.LatLng(ne.lat(), ne.lng())
          // 将左下角的坐标转为谷歌地图坐标
          let swLngLat = new google.maps.LatLng(sw.lat(), sw.lng())
          // 将gps坐标转为平面直角坐标系的坐标
          fromLngLat = projection.fromLatLngToDivPixel(fromLngLat );
          toLngLat = projection.fromLatLngToDivPixel(toLngLat);
          let bashX = projection.fromLatLngToDivPixel(neLngLat).x;  // 获取可视区域左边的坐标
          let bashY = projection.fromLatLngToDivPixel(swLngLat).y	// 获取可是区域上边的坐标
          let res = getCosFromTan(fromLngLat, toLngLat , isContract);	// 获取二次贝塞尔曲线的控制点的相对于两点的坐标

          if (isContract == true) {
            isContract = false;
          } else {
            isContract = true;
          }
          randomBash =  (randomBash + 1) % 10;
          // 获取贝塞尔曲线的控制点坐标
          res.x = bashX + res.x * (randomBash / 10) + (toLngLat .x + fromLngLat.x) / 2;  
          res.y = bashY + res.y * (randomBash / 10) + (toLngLat .y + fromLngLat.y) / 2;
          let color = that.getValeColor(flyObj.value, rankUnit)

          return d3.select(this)
          			// 绘制贝塞尔曲线
                  .attr('d', 'M ' + (bashX + fromLngLat.x) + ',' + (bashY + fromLngLat.y) + ' Q ' + res.x + ',' + res.y + ' ' + (bashX + toLngLat .x) + ',' + (bashY + toLngLat .y))
                  .attr('stroke', color)
                  .attr('stroke-width', '1')
                  .attr('fill', 'none')
                  .attr('marker-end', 'url(#markerArrow)');
        }
}

这里留下一个分割线来解释一下那个求控制点方法的函数。。。


  • 首先,先创建当前谷歌地图的layer,这个新建的对象回去监听地图的重绘和图层的添加完毕事件,但是这个事件的回调函数需要我们去定义。
  • 当layer创建完毕的时候,进行初始化svg标签,以后的图都要在这个svg标签上面绘制东西,最好是给这个svg添加一个类,然后将svg调整为宽度是100vw,100vh大小,并且要讲这个svg图完全显示到可视区域上(使用以下css样式)。
// 这个图层是固定在可视区域的(也正是这样,需要在draw方法中每次重新绘制飞线)。并且是从页面的中点开始绘制的,所以需要将其调整一下。
{
  width: 100vw;
  height: 100vh;
  position: absolute;
  top: -50vh;
  left: -50%;
}
  • 在draw回调函数中利用数据添加飞线
  • 为什么top和left要进行设置-50%?这是因为svg的画板的横纵坐标是从可视区域的中点开始画起的。我们要的是可视区域都是svg的画板,所以需要尽心设置偏移。 明天再回来补充、先睡了

补充,之前还没有讲完,现在进行补充完毕


利用数据进行添加飞线。需要用到d3数据可视化库的一个selectAll选择已经渲染后的节点,并且进行遍历数据进行渲染。我们在draw代码中提取对数据进行更新的代码。

layer.selectAll('.flyLines')
              .data(data)   // 进行遍历数据(数据要以一个数组的形式)
              .each(transform)  // 这个是数据的遍历,这里transform是对每个数据的回调

上面的each的用法类似于JavaScript中对于数组的遍历的用法(针对于被遍历的数组每个数据进行设置针对于google map的操作)。
我先说transfrom的作用:不加这个方法的话,所有的线都是执行,所以没有那种弯曲的感觉,transform的作用就是将线条变成飞线。

接下来我们顺藤摸瓜找到了transform方法,如下代码。

function transform(flyObj) {
              // 将原生的gps坐标改为谷歌地图坐标
              let fromLngLat = new google.maps.LatLng(flyObj.fromLnglat[1], flyObj.fromLnglat[0]);
              let toLngLat = new google.maps.LatLng(flyObj.toLnglat[1], flyObj.toLnglat[0]);
              // 将右上角的坐标转为谷歌地图坐标
              let neLngLat = new google.maps.LatLng(ne.lat(), ne.lng())
              // 将左下角的坐标转为谷歌地图坐标
              let swLngLat = new google.maps.LatLng(sw.lat(), sw.lng())
              // 将gps坐标转为平面直角坐标系的坐标
              fromLngLat = projection.fromLatLngToDivPixel(fromLngLat );
              toLngLat = projection.fromLatLngToDivPixel(toLngLat);
              let bashX = projection.fromLatLngToDivPixel(neLngLat).x;  // 获取可视区域左边的坐标
              let bashY = projection.fromLatLngToDivPixel(swLngLat).y	// 获取可是区域上边的坐标
              let res = getCosFromTan(fromLngLat, toLngLat , isContract);	// 获取二次贝塞尔曲线的控制点的相对于两点的坐标

              if (isContract == true) {
                isContract = false;
              } else {
                isContract = true;
              }
              randomBash =  (randomBash + 1) % 10;
              // 获取贝塞尔曲线的控制点坐标
              res.x = bashX + res.x * (randomBash / 10) + (toLngLat .x + fromLngLat.x) / 2;  
              res.y = bashY + res.y * (randomBash / 10) + (toLngLat .y + fromLngLat.y) / 2;
              let color = that.getValeColor(flyObj.value, rankUnit)

              return d3.select(this)
              // 绘制贝塞尔曲线
                      .attr('d', 'M ' + (bashX + fromLngLat.x) + ',' + (bashY + fromLngLat.y) + ' Q ' + res.x + ',' + res.y + ' ' + (bashX + toLngLat .x) + ',' + (bashY + toLngLat .y))
                      .attr('stroke', color)
                      .attr('stroke-width', '1')
                      .attr('fill', 'none')
                      .attr('marker-end', 'url(#markerArrow)');
            }

首先要明确我们要干什么?

  • 得到可是区域左上角在谷歌的DivPixel坐标作为基础的坐标(相对于谷歌的平面坐标系的坐标)
  • 得到出发点的坐标以及终止点的坐标(仅仅是相对于svg画板的左上角的坐标)
  • 根据出发点和终点求出贝塞尔曲线控制点的坐标(仅仅是相对于svg画板左上角的坐标)
  • 将控制点、起点、终点都加上基础坐标

这样就可以进行确定我们所要显示的坐标在画布上的位置(包括起点、终点、控制点),接下来就可以通过svg矢量图来进行绘制贝塞尔曲线了。接下来我就直接晒出用d3来画的成果吧。我这里就不给出项目地址了。

在这里插入图片描述
这个是显示所有的飞线的效果图。大概就几万条,如果用canvas来进行绘制的话,可能会因为现实内容过多将浏览器卡死。
而用svg矢量图的话,它是作为dom节点会知道html上面的,会比canvas性能好一点。但也不会好到哪里去(因为放大或者缩小、移动地图的时候,svg里面的标签需要重新渲染(因为画布永永远是跟随着用户的,并且只有屏幕大小,只会渲染屏幕现实的内容,所以移动一次交渲染一次))。
如果是dom标签的渲染的话,就回到了我们的舒适圈了。进行优化的话我这里就不列出代码了,提供几个思路。

  1. 减少渲染的次数,比如说按住地图进行拖动的时候,不是一移动屏幕就重新渲染,而是等到用户停止移动的时候才会进行渲染。(默认是只要用户一移动屏幕,就会重新渲染)
  2. 如果听说过线程池的概念的话,就很好理解了。渲染dom节点花费的性能很贵的,那么我们就造一个dom节点池来进行优化。 最后的最后,我还是人性化一点,贴几张能看的google map + d3的飞线效果图吧。

在这里插入图片描述

最后如果想要我的源码的话,请到

Github: MapDemo

如果觉得可以的话,请点个赞并且Star一下谢谢!