乐趣区

d3js制作连线动画图和编辑器

效果如上图所示。
本项目使用主要 d3.jsv4 制作,分两部分,一个是实际展示的连线动画图,另一个是管理人员使用鼠标编辑连线的页面。对于 d3.js 如何引入图片,如何画线等基础功能,这里就不再介绍了,大家可以找一些入门文章看一下。这里主要介绍一下重点问题。

1. 连线动画图

此图的主要功能是每隔给定时间,通过 ajax 请求后台数据,并根据返回的数据动态改变每个图片下方的数值,动态改变连线上的动画流动方向和是否流动。
首先,确定图表中需要配置的内容,如各图片存储位置,连线和动画颜色,图片和连线的坐标等。这些数据需要在 html 中进行配置,最好写成 object 对象,赋值给我们自己的图表类的函数。比如:

var data = {
  element:[{
    image: 'img/work.png',
    pos:[1,1], // 图片位置
    linePoint:[], // 图片发出线段坐标数组
    lineDir:0, // 线段动画方向
    title: '工作'
  }],
  lineColor:'black', // 连线颜色
  animateColor: 'red', // 动画颜色
};
var chart = new Myd3chart('#chart');
chart.lineChart(data);

其中图片发出的线段坐标数组,使用外部文件提供,此文件由之后介绍的编辑器生成。
在设计我们自己的图表函数时,最好把每个功能划分成独立的函数,这样方便以后的维护和扩展。
动画线段采用 css 的方式,有动画的线段添加此 css 即可:

.animate-line{
  fill: none;
  stroke-width: 1;
  stroke-dasharray: 50 100;
  stroke-dashoffset: 0;
  animation: stroke 6s infinite linear;
}
@keyframes stroke {
  100% {stroke-dashoffset: 500; /* 如果反向移动改为 -500 */}
}

这个图表的难点在于动态改变连线上的流动动画,因为 A 线段的终点会连接到 B 线段上,如果 B 线段动画停止,则 A 线段上的动画仍然要从 B 上经过,而不能简单停止 B 线段上的动画。而且如果 B 线段上的接入点不止一个,还要判断接入点之间的顺序,只显示最靠近 B 起始点的接入点的动画。另外还要判断接入线段上是否有接入线段,层级关系里面如果有 1 个线段有动画,则此接入点就有动画流出。(这里说起来有点绕)
我的方法是:
1)统计每个线段上的所有接入点,这里就是图片名称,用于判断此线段是否有动画流出。
2)接收后台传来的数据时,判断每个线段是否有动画,如果有动画,则直接恢复其动画线段的起始点坐标;如果没有动画,则判断最靠近起始点的接入点是否有动画,如果有动画则将动画线段的起始点改为此接入点坐标。

// 统计接入点
  function findAccessPoint() {var accessPoints = [];
    // 记录每个线段上的接入点,data 为配置数据
    data.eles.forEach(function(d, i){if(d.line.length == 0){return;}
      var acsp = {
        name: d.title.text,
        ap: [], // 接入点, 按顺序排列,头部离开始点近};
      // 本线段上,每两相邻的点作为一个元素存入数组
      var linePair = [];
      // 本线段起始点
      var startPos = d.line[0];
      d.line.forEach(function(dd, di){if(d.line[di+1]){
          var pair = {
            start: dd,
            end: d.line[di+1]
          };
          linePair.push(pair);
        }        
      });
      // 对每两相邻的点,查找接入点
      linePair.forEach(function(dd, di){chartData.eles.forEach(function(ddd, ddi){
          // 排除自己,查找自己线段上的接入点
          if(i != ddi && ddd.line.length > 1){
            // 得到此线段终点
            var pos = ddd.line[ddd.line.length - 1];
            // dd.start 开始点,dd.end 结束点
            // 用 x 坐标计算在本线段上的 y 坐标,再和实际的 y 坐标比较
            var computeY = dd.start[1] + 
              (pos[0] - dd.start[0])*(dd.end[1] - dd.start[1])/(dd.end[0] - dd.start[0]);
            var dif = Math.abs(computeY - pos[1]);
            // 如果误差在 2 以内,并且此线终点在当前线起点和终点之间
            // 认为此点为接入点
            if(dif < 2 && (
              (((pos[0] > dd.start[0]) && (pos[0] < dd.end[0])) ||
                ((pos[0] < dd.start[0]) && (pos[0] > dd.end[0]))
              ) && (((pos[1] > dd.start[1]) && (pos[1] < dd.end[1])) ||
                ((pos[1] < dd.start[1]) && (pos[1] > dd.end[1]))
              )
            )) {var dis = Math.pow((pos[0] - startPos[0]),2) + Math.pow((pos[1] - startPos[1]),2);
              var ap = {
                name: ddd.title.text,
                ap: pos,
                distance: dis, // 距离起始点的距离
                allNames: [], // 所有通过此接入点的站点名称}
              acsp.ap.push(ap);            
            }
          }
        });
      })
      accessPoints.push(acsp);
    });

    // 对所有的接入点,按与起始点的距离排序,并查找此接入点的上层站点
    accessPoints.forEach(function(d, i){
      // 按 distance 由小到大排序
      d.ap.sort(function(a, b){return a.distance - b.distance;});
      // 查找每个接入点的上层站点
      d.ap.forEach(function(dd, di){findPoint(dd.name, dd.allNames);
      });
    });
    // name 是接入点名称,arr 是该接入点的 allNames
    function findPoint(name, arr){accessPoints.forEach(function(d, i){
        // 在数组中找到指定名称的项
        if(d.name === name){if(d.ap.length>0){
            // 把该项下面的 ap 中的名称加入给定 arr
            d.ap.forEach(function(dd, di){arr.push(dd.name);
              // 如果该点内的 allNames 已经有值则直接加入
              if(dd.allNames.length>0){dd.allNames.forEach(function(d, i){arr.push(d);
                });
              } else{
                // 递归查找子接入点
                findPoint(dd.name, arr);
              }
            });
          } else {return;}
        }else{return;}
      });
    }
  }

以上函数的运行结果会产生一个对象,存储每个接入线段上‘挂载’的接入点,目的就是改变动画时方便判断。

// 更新线条动画
    aniLine.each(function(d, i){var curLine = d3.select(this);
        // 找到对应的动画 line
        if (dd.name === curLine.attr('tag')) {
          // 处理动画是否运行
          if (dd.ani) {
            // 此线条动画运行
            curLine.style('animation-play-state', 'running');
            curLine.style('display', 'inline');
            // 如果动画运行,则恢复原始动画路径
            curLine.attr('d', function(d){return line(chartData.eles[i].line);
            });
          } else {
            // 此线条动画停止
            // 先查找离本线段开始点最近的接入点
            var acp = accessPoints;
            // 从 accessPoints 中找到本节点的接入点集合
            var ap = [];
            acp.forEach(function(acd, aci){if(acd.name === dd.name){ap = acd.ap;}
            });            
            // 最近有动画接入点序号
            var acIndex = -1;
            // 找到最近的有动画接入点,远近按数组序号递增
            for(var j=0;j<ap.length;j++){
              // 复制所有子接入点数组
              var allNames = ap[j].allNames.concat();
              // 将接入点名称也加入
              allNames.push(ap[j].name);
              // 判断此接入点树中是否有动画,如果 1 个有就可以
              allNames.forEach(function(name,ani){data.forEach(function(datad, datai){if(datad.name === name){if(datad.ani){
                      acIndex = j;
                      return;
                    }
                  }
                });
              });
              if(acIndex != -1) {break;}
            }
            // 如果存在有动画接入点
            if(acIndex != -1){curLine.style('animation-play-state', 'running');
              curLine.style('display', 'inline');
              curLine.attr('d', function(d){var accp = ap[acIndex].ap;
                var curLine = data.element[i].line.concat();
                // 接入节点与开始点的距离
                var disAp = Math.pow((accp[0] - curLine[0][0]),2) +
                Math.pow((accp[1] - curLine[0][1]),2);
                // 如果当前线段中有离开始节点比接入点近的节点
                // 则删除此节点
                curLine.forEach(function(curld, curli){if(curli > 0){var dis = Math.pow((curld[0] - curLine[0][0]),2) +
                      Math.pow((curld[1] - curLine[0][1]),2);
                    if(dis < disAp){
                      // 删除此点
                      curLine.splice(curli,1);
                    }
                  }
                });
                // 从此接入点处开始动画
                curLine.splice(0,1,accp);
                // debugger;
                return line(curLine);
              });
            }else{
              // 此线条动画停止
              curLine.style('animation-play-state', 'paused');
              curLine.style('display', 'none');
            }
          }
        }

2. 编辑器

由于本图表需要配置大量坐标,如果手动填写的话效率十分低下,所以需要开发一个编辑器用来修改图表。
编辑器的主要使用方法为,使用鼠标拖动图标,双击确定起始位置并开始实时画线状态,随着鼠标移动动态画出线段,单击确定临时终点,再单击确定下一个终点,右击结束动态画线状态。如果鼠标单击其他图标,则终点为该图标的起始坐标。本程序的实时画线部分进行了倾斜的约束,即左倾或右倾 30 度角。
编辑器比展示图要简单一些,复杂部分在事件处理。

// 拖动图标
    var draging = d3.drag()
      .on('drag', function () {// 当长宽相同时,iconSize 是图标大小 [ 宽,高]
        var move = iconSize[0] / 2,
          moveSubBg = [25, 53.5], moveTitle = [25, 50];
        var g = d3.select(this),
          eventX = d3.event.x - move,
          eventY = d3.event.y - move;
        // 设定图标位置
        g.select('.image')
          .attr('x', eventX)
          .attr('y', eventY);
      })
      // 拖拽结束
      .on('end', function () {var g = d3.select(this);
        g.select('.subBg')
          .attr('transform', function (d, i) {
          // 对子标签的处理,自动符合字符串长度
            var x = parseFloat(d3.select(this).attr('x')) + parseFloat(d3.select(this).attr('width')) / 2,
              // y 没被缩放,所以不用处理
              y = d3.select(this).attr('y'),
              dsl = (d.title.subTitle.text + '').length;
            var scaleX = dsl * 5.5;
            return 'translate(' + x + '' + y +') scale('+ scaleX +', 1) translate('+ -x +' '+ -y +')';
          });
      });
    // 图标组增加拖动事件
    imageGs.call(draging);

以上拖动事件,只是调用基本方法。
实时画线功能需要提前定义临时存储对象,用来存储鼠标移动时线段的终点坐标。

// 鼠标移动时,实时画线到鼠标当前位置,_bodyRect 为主区域
    _bodyRect.on('mousemove', function(){
      // 如果不处于实时画线状态
      if(!_chartData.drawing){return;}
      // 如果没有端点名称
      if (!_chartData.linePrePare.name) {return;}
      /* 实时画线 */
      // 判断线段倾斜方向,linePrePare 为线段临时存储
      var preLines = linePrePare.lines;
      var mousePos = d3.mouse(_bodyRect.node()),
        beforePos = preLines[preLines.length - 1], newy,
        newPos = [];
      if((mousePos[0]>beforePos[0] && mousePos[1]>beforePos[1]) || (mousePos[0]<beforePos[0] && mousePos[1]<beforePos[1])){// 向左倾斜 \ 左上到右下:y = cy + 0.7*(x-cx)
        newy = beforePos[1] + 0.7 * (mousePos[0] - beforePos[0]);
      } else {// 向右倾斜 / 左下到右上:y = cy - 0.7*(cx-x)
        newy = beforePos[1] - 0.7 * (mousePos[0] - beforePos[0]);
      }
      newPos = [mousePos[0], newy];
      // 移除旧线
      if(_chartData.tempLine.line){_chartData.tempLine.pos = [];
        _chartData.tempLine.line.remove();}
      // 画新线,tempLine 为实时画线的临时存储
      _chartData.tempLine.line = _chartData.lineRootG.append('path')
        .attr('class', 'line-path')
        .attr('stroke', chartData.line.color)
        .attr('stroke-width', chartData.line.width)
        .attr('fill', 'none')
        .attr('d', function () {
          var newLine = [preLines[preLines.length - 1],
            newPos
          ];
          _chartData.tempLine.pos = newPos;
          return line(newLine);
        });

      // 当鼠标移入某个建筑图标范围时
      _chartData.imageGs.on('mouseenter', function(d, i){
        // 移除旧线
        if(_chartData.tempLine.line){_chartData.tempLine.pos = [];
          _chartData.tempLine.line.remove();}
        // 得到图标中心点坐标
        var posX = parseFloat(d3.select(this).select('.image').attr('x')) + _chartConf.baseSize[0] / 2;
        var posY = parseFloat(d3.select(this).select('.image').attr('y')) + _chartConf.baseSize[1] / 2;
        // 将此建筑图标的中心点坐标作为终点坐标画线
        _chartData.tempLine.line = _chartData.lineRootG.append('path')
          .attr('class', 'line-path')
          .attr('stroke', chartData.line.color)
          .attr('stroke-width', chartData.line.width)
          .attr('fill', 'none')
          .attr('d', function () {
            var newLine = [preLines[preLines.length - 1],
              [posX,posY]
            ];
            _chartData.tempLine.pos = [posX,posY];
            return line(newLine);
          });
      });
      // 当鼠标移出图标区域
      _chartData.imageGs.on('mouseleave', function(d, i){
        // 移除旧线
        if(_chartData.tempLine.line){_chartData.tempLine.pos = [];
          _chartData.tempLine.line.remove();}
      });
      // 对图标单击鼠标,保存线
      _chartData.imageGs.on('click', function (d, i) {
        // 保存临时线
        drawLine();
        // 停止实时画线
        exitDrawing();});
    });
    // 点击鼠标右键, 停止实时画线
    _bodyRect.on('contextmenu', function(){
      // 停止实时画线
      exitDrawing();
      d3.event.preventDefault();});
   });
  }

在此只贴出部分代码,如果大家有任何建议和问题,还请留言,谢谢。

退出移动版