手把手教你DIY一个春运迁徙图(一)

news/2025/2/27 10:33:01

  换了新工作,也确定了我未来数据可视化的发展方向。新年第一篇博客,又逢春运,这篇技术文章就来交给大家如何做一个酷炫的迁徙图(支持移动哦)。(求star 代码点这里)

   迁徙图的制作思路分为静态的元素和变换的动画。其中动画是围绕着静态的元素变换,所以我们首要的任务就是如何绘制静态的元素。

  仔细看一下,静态的元素分为弧线(Arc)、弧线端点的箭头(Marker),动画部分主要是弧线终点向脉冲波一样的圆(Pulse),以及像流星一样的动态小箭头和弧线的光晕,这两个我们放在一起成为Spark。我们可以看到Spark主要在弧线上运动,如果你仔细观察一下会发现终点处点头的指向也是朝着终点处的切线方向,所以我们把主要的任务放在如何根据两个点绘制一段弧线。

  我们要绘制这段弧线就要知道圆心和半径,而数学定理告诉我们的是三点定圆,过两个点的圆有无数个,所以我们只能找一个比较合适的圆。

  所以现在的问题变成了已知两点pointF和pointT,求一个合适的圆心pointC (xc, yc);

  根据pointF和pointT所以我们能够确定一条直线,他的斜率 kt =(yt - yf)/ (xt - xf);

  根据PointF和pointT我们能够计算出他们的中点pointH=(m, n); m = (xt - xf) / 2, n = (yt - yf) / 2;

  经过两点的圆一定在他们两点的中垂线上,而直线的中垂线斜率kl与直线的斜率kt存在数学关系:kl * kt = -1;

  把我们的参数全部套入这个公式可得:

  ((yc - n)/ (xc - m)) * ((yt - yf)/ (xt - xf)) = -1;

  接着变换一下:

  (yc - n) / (xc - m) = -(xt - xf) / (yt - yf);

  去掉碍事的负号:

  (yc - n) / (xc - m) = (xt - xf) / (yf - yt);

  再变换一下:

  (yc - n)/ (xt - xf) = (xc - m) / (yf - yt) = factor;

  到此我们得到:

  yc - n = (xt - xf) * factor;

  xc - m = (yf - yt) * factor;

  这两行公式中都各存在两个位置参数(yc、factor) 和 (xc、factor);所以只要找到一个合适的factor就能够得到合适的圆心进而得到半径起始角和终止角以及半径。有了这些那么Marker的指向、Spark的轨迹都可以确定了。

 

  现在需要做的是把上述过程转换为代码:

var Arc = (function() {
    var A = function(options) {
      var startX = options.startX,
      startY = options.startY,
      endX = options.endX,
      endY = options.endY;

      //两点之间的圆有多个,通过两点及半径便可以定出两个圆,根据需要选取其中一个圆
      var L = Math.sqrt(Math.pow(startX - endX, 2) + Math.pow(startY - endY, 2));
      var m = (startX + endX) / 2; // 横轴中点
      var n = (startY + endY) / 2; // 纵轴中点
      var factor = 1.5;

      var centerX = (startY - endY) * factor + m;
      var centerY = (endX - startX) * factor + n;

      var radius = Math.sqrt(Math.pow(L / 2, 2) + Math.pow(L * factor, 2));
      var startAngle = Math.atan2(startY - centerY, startX - centerX);
      var endAngle = Math.atan2(endY - centerY, endX - centerX);

      // this.L = L;
      this.startX = startX;
      this.startY = startY;
      this.endX = endX;
      this.endY = endY;
      this.centerX = centerX;
      this.centerY = centerY;
      this.startAngle = startAngle;
      this.endAngle = endAngle;
      this.startLabel = options && options.labels && options.labels[0],
      this.endLabel = options && options.labels && options.labels[1],
      this.radius = radius;
      this.lineWidth = options.width || 1;
      this.strokeStyle = options.color || '#000';
      this.shadowBlur = options.shadowBlur;
    };

    A.prototype.draw = function(context) {
      context.save();
      context.lineWidth = this.lineWidth;
      context.strokeStyle = this.strokeStyle;
      context.shadowColor = this.strokeStyle;
      context.shadowBlur = this.shadowBlur || 2;

      context.beginPath();
      context.arc(this.centerX, this.centerY, this.radius, this.startAngle, this.endAngle, false);
      context.stroke();
      context.restore();

      context.save();
      context.fillStyle = this.strokeStyle;
      context.font = "15px sans-serif";
      if (this.startLabel) {
        context.fillText(this.startLabel, x, y);
      }
      if (this.endLabel) {
        context.fillText(this.endLabel, x, y);
      }
      context.restore();
    };

    return A;
  })();

  

  理解了上述过程,我们就已经成功了一半。下一步的重点就是动画的绘制。关于动画首先要了解requestAnimationFrame,不知道的小伙伴要去恶补一下啦。好啦,言归正传。Spark动画分为两部分,一部拖尾效果,一部分是弧线光晕效果,当你第一次打开时候会发现,弧线光晕会随着小箭头运动,到达终点后光晕停止运动,剩下小箭头自己运动。由于每个圆的大小不一致,我们需要在每次动画过程中控制光晕和小箭头的位置。

   由于他们是在圆弧上运动,所以我们只要每次计算出他们新的弧度就可以了,弧度的步长可以这样来制定:每走过20像素所转过的弧度就是各个Spark的步长啦。

  所以factor = 20 / radius;

  每次绘制时,光晕与小箭头的弧度位置为:

var endAngle = this.endAngle;
      // 匀速
      var angle = this.trailAngle +  this.factor;

  弧度确定之后我们就能得到小箭头的位置,但是目前并不能得到小箭头的方向。根据canvas中角度的特点,再由简单的几何知识,可以得到小箭头的旋转方向应该为:rotation = angle + Math.PI / 2;

  

  目前为止我们解决了Spark动画中的两大问题,剩下了最后一个:拖尾效果。看起来由粗到细这段就是拖尾效果。

  

  实际上为了保证在移动端的性能,本次实例中并没有明显的拖尾。但拖尾还是一个比较常见的特效,所以我们需要把它掌握。拖尾效果一般是对一个元素进行多次复制,并线性的渐变这队影元素的大小宽度以及颜色的透明度在达到由粗到细由大到小颜色有深变浅的效果。那么每次绘制时候都需要知道这队影元素每个的位置,每个的线宽以及每个的颜色,根据上面讨论的元素位置需要根据弧度来确定。我们说过他们的位置是渐变的,渐变的步长可以这样指定,假设从头到尾的弧长为80,那么每个影元素的之间的间隔为:

this.deltaAngle = (80 / Math.min(this.radius, 400)) / this.tailPointsCount;

  由此便可绘制出拖尾效果:

// 拖尾效果
      var count = this.tailPointsCount;
      for (var i = 0;  i < count; i++) {
        var arcColor = utils.calculateColor(this.strokeStyle, 0.3-0.3/count*i);
        var tailLineWidth = 5;
        if (this.trailAngle - this.deltaAngle * i > this.startAngle)  {
          this.drawArc(context, arcColor,
            tailLineWidth - tailLineWidth / count * i,
            this.trailAngle - this.deltaAngle * i,
            this.trailAngle
          );
        }
      }

  所以整个Spark的代码如下:

var Spark = (function() {
    var S = function(options) {
      var startX = options.startX,
      startY = options.startY,
      endX = options.endX,
      endY = options.endY;

      //两点之间的圆有多个,通过两点及半径便可以定出两个圆,根据需要选取其中一个圆
      var L = Math.sqrt(Math.pow(startX - endX, 2) + Math.pow(startY - endY, 2));
      var m = (startX + endX) / 2; // 横轴中点
      var n = (startY + endY) / 2; // 纵轴中点
      var factor = 1.5;

      var centerX = (startY - endY) * factor + m;
      var centerY = (endX - startX) * factor + n;

      var radius = Math.sqrt(Math.pow(L / 2, 2) + Math.pow(L * factor, 2));
      var startAngle = Math.atan2(startY - centerY, startX - centerX);
      var endAngle = Math.atan2(endY - centerY, endX - centerX);

      // 保证Spark的弧度不超过Math.PI
      if (startAngle * endAngle < 0) {
        if (startAngle < 0) {
          startAngle += Math.PI * 2;
          endAngle += Math.PI * 2;
        } else {
          endAngle += Math.PI * 2;
        }
      }

      this.tailPointsCount = 5; // 拖尾点数
      this.centerX = centerX;
      this.centerY = centerY;
      this.startAngle = startAngle;
      this.endAngle = endAngle;
      this.radius = radius;
      this.lineWidth = options.width || 5;
      this.strokeStyle = options.color || '#000';
      this.factor = 2 / this.radius;
      this.deltaAngle = (80 / Math.min(this.radius, 400)) / this.tailPointsCount;
      this.trailAngle = this.startAngle;
      this.arcAngle = this.startAngle;

      this.animateBlur = true;

      this.marker = new Marker({
        x: 50,
        y:80,
        rotation: 50 * Math.PI / 180,
        style: 'arrow',
        color: 'rgb(255, 255, 255)',
        size: 2,
        borderWidth: 0,
        borderColor: this.strokeStyle
      });
    };

    S.prototype.drawArc = function(context, strokeColor, lineWidth, startAngle, endAngle) {
      context.save();
      context.lineWidth = lineWidth;
      // context.lineWidth = 5;
      context.strokeStyle = strokeColor;
      context.shadowColor = this.strokeStyle;
      // context.shadowBlur = 5;
      context.lineCap = "round";
      context.beginPath();
      context.arc(this.centerX, this.centerY, this.radius, startAngle, endAngle, false);
      context.stroke();
      context.restore();
    };

    S.prototype.draw = function(context) {
      var endAngle = this.endAngle;
      // 匀速
      var angle = this.trailAngle + (endAngle - this.startAngle) * this.factor;
      var strokeColor = this.strokeStyle;
      if (this.animateBlur) {
        this.arcAngle = angle;
      }
      this.trailAngle = angle;
      strokeColor = utils.calculateColor(strokeColor, 0.1);

      this.drawArc(context, strokeColor, this.lineWidth, this.startAngle, this.arcAngle);

      // 拖尾效果
      var count = this.tailPointsCount;
      for (var i = 0;  i < count; i++) {
        var arcColor = utils.calculateColor(this.strokeStyle, 0.3-0.3/count*i);
        var tailLineWidth = 5;
        if (this.trailAngle - this.deltaAngle * i > this.startAngle)  {
          this.drawArc(context, arcColor,
            tailLineWidth - tailLineWidth / count * i,
            this.trailAngle - this.deltaAngle * i,
            this.trailAngle
          );
        }
      }

      context.save();
      context.translate(this.centerX, this.centerY);
      this.marker.x = Math.cos(this.trailAngle) * this.radius;
      this.marker.y = Math.sin(this.trailAngle) * this.radius;
      this.marker.rotation = this.trailAngle + Math.PI / 2;
      this.marker.draw(context);
      context.restore();

      if ((endAngle - this.trailAngle) * 180 / Math.PI < 0.5) {
        this.trailAngle = this.startAngle;
        this.animateBlur = false;
      }
    };

    return S;
  })();
Spark源码

   到目前为止,迁徙图中主要的技术难点就已经讲完了。但如何把它放到地图上,这个问题我们将在下篇文章中讨论。


http://www.niftyadmin.cn/n/634830.html

相关文章

Oracle 日期格式化处理汇总

一、 日期及时间格式化应用TO_CHAR(日期&#xff0c;格式化参数) 1、返回任意有效分割符拼接的年月日字符串 <pre name"code" class"sql"><span style"font-size:18px;">1.1、Select to_char(sysdate,yyyy/mm/dd) From dual; *****…

哈希表(散列表)原理详解

什么是哈希表&#xff1f; 哈希表&#xff08;Hash table&#xff0c;也叫散列表&#xff09;&#xff0c;是根据关键码值(Key value)而直接进行访问的数据结构。也就是说&#xff0c;它通过把关键码值映射到表中一个位置来访问记录&#xff0c;以加快查找的速度。这个映射函数…

Mysql事务-隔离级别

MYSQL事务-隔离级别 事务是什么? 事务简言之就是一组SQL执行要么全部成功&#xff0c;要么全部失败。MYSQL的事务在存储引擎层实现。 事务都有ACID特性&#xff1a; 原子性&#xff08;Atomicity&#xff09;&#xff1a;一个事务必须被视为一个不可分割的单元&#xff1b; 一…

Top K 算法详解(哈希表Hash的使用)

要说明这个问题&#xff0c;还是用实例说明比较容易理解&#xff0c;看下面一道百度面试题&#xff1a; 搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来&#xff0c;每个查询串的长度为1-255字节。 假设目前有一千万个记录&#xff08;这些查询串的重复度…

numpy , pandas 划分bins

numpy 中划分bins&#xff0c;并计算一个bin内的均值 import numpy data np.array([range(100)]) bins numpy.linspace(0, 50, 10) binsnp.append(bins,np.inf)#最后一个bin到无穷大 digitized numpy.digitize(data, bins)#Return the indices of the bins to which each v…

求导四则运算以及三角函数求导 Derivative formulas

对特定函数的求导。 1&#xff1a;sin&#xff08;x&#xff09; 对其进行求斜率。带入公式得&#xff1a;[ sin&#xff08;xΔx&#xff09;- sin&#xff08;x&#xff09;]/Δx [ sinx*cosΔx cosx*sinΔx -sin x ]/ Δx [ cos x * sin Δx ] / Δx cos x cos Δx 1 …

Oracle 把某一列的多行数据拼接为一个字符串

业务需求&#xff1a; 在Oracle中把某一列的多行数据拼接为一个字符串&#xff0c;如下&#xff1a; 转为 关键知识点sys_connect_by_path 【引自度娘】在Oracle中&#xff0c;SYS_CONNECT_BY_PATH函数主要作用是可以把一个父节点下的所有子节点通过某个字符进行区分&#…

Oracle 复制指定Id下相关记录及其对应所有子表(包含子表的子表)下的记录

之前做PDM的时候&#xff0c;曾遇到过一个复制的业务需求&#xff0c;具体要求是根据顶层表的节点&#xff0c;复制其及其所有相关子表下的记录&#xff08;当然主键不用复制&#xff09;&#xff0c;其实技术并不复杂&#xff0c;只是当时卡在了如何把复制过程中的一级级的新的…