【实现自己的可视化引擎06】折线图

1,248 阅读7分钟

前言

本章节,我们将实现图表中的折线图。
首先我们先确认一下我们需要绘制图形的数据格式:

[
  { item: '2000', a: 60, b: 153 },
  { item: '2001', a: 67, b: 134 },
  { item: '2002', a: 64, b: 133 },
  { item: '2003', a: 78, b: 148 },
  { item: '2004', a: 65, b: 165 },
  { item: '2005', a: 66, b: 188 },
  { item: '2006', a: 70, b: 145 },
  { item: '2007', a: 80, b: 173 },
  { item: '2008', a: 88, b: 177 },
  { item: '2009', a: 90, b: 168 },
  { item: '2010', a: 76, b: 187 },
  { item: '2011', a: 77, b: 196 },
  { item: '2012', a: 89, b: 200 },
  { item: '2013', a: 65, b: 173 },
  { item: '2014', a: 79, b: 190 },
]

item为X轴坐标值,其余属性为各折线的属性值。

基本的折线图

基础的折线图我们可以简单的看作多个线图与坐标层组合,效果图如下:

线图层的逻辑很简单,我们只需解析上面的数据格式,计算出各属性值所对应的坐标点,然后绘制成线即可,算法如下:

  1. 遍历数据确定数据的最大值及最小值;
  2. 根据数据最大值与最小值,计算单位数值所占用的长度;
  3. 遍历数据,计算各个数据的坐标点;
  4. 根据坐标点,绘制MultiLine对象,并加入线图层;
    代码如下:
make() {
    this.childs.splice(0, this.childs.length);
    if(this.data.length === 0) {
      return;
    }
    let max = -99999999; // 数据最大值
    let min = 99999999; // 数据最小值
    //遍历数据,确定最大值与最小值
    for (let i = 0; i < this.data.length; i++) {
      for (let key in this.data[i]) {
        if (key !== 'item') {
          if (max < this.data[i][key]) {
            max = this.data[i][key];
          }
          if (min > this.data[i][key]) {
            min = this.data[i][key];
          }
        }
      }
    }
    // 计算Y方向单位数值所占用的长度
    const yStep = this.height / (max - min);
    // 计算X方向单位数值所占用的长度
    const xStep = this.width / this.data.length;
    // 记录各线条所在的点
    const linePoints = {};
    // 遍历数据计算点的位置
    for (let i = 0; i < this.data.length; i++) {
      const data = this.data[i];
      for(let key in data) {
        if (key !== 'item') {
          if (!linePoints[key]) {
            linePoints[key] = [];
          }
          linePoints[key].push(
            new Point(
              this.position.x + i * xStep,
              this.position.y + (data[key] - min) * yStep
            )
          );
        }
      }
    }
    // 遍历所有的点数据,绘制相应曲线
    for (let key in linePoints) {
      let line = new MultiLine(this.canvas, {
        ...this.style,
        color: this.colors[key],
        position: linePoints[key][0],
      }, linePoints[key]);
      this.addChild(line);
    }
    this.onMaked && this.onMaked(this, {
      xStep,
      yStep,
      yMax: max,
      yMin: min,
    });
}

面积图

如图所示,面积图与普通折线图的不同之处在于叠加了一层带有透明度的与坐标轴闭合的多变形,所以,我们只需在绘制线条的时候线图层添加一个多边形即可。代码如下:

// 遍历所有的点数据,绘制相应曲线
for (let key in linePoints) {
  let line = new MultiLine(this.canvas, {
    ...this.style,
    color: this.colors[key],
    position: linePoints[key][0],
  }, linePoints[key]);
  // 绘制面积图
  if (this.type === LineLayer.TYPE.FILL) {
    let polygon = new Polygon(this.canvas, {
      color: this.colors[key],
      type: Polygon.TYPE.FILL,
    }, [
      new Point(linePoints[key][0].x, 20),
      ...linePoints[key],
      new Point(linePoints[key][linePoints[key].length - 1].x, 20)
    ]) // 增加前后两个点使其闭合
    polygon.alpha = 0.4; // 修改透明度
    this.addChild(polygon);
  }
  this.addChild(line);
}

当面积图穿过0轴时,一个线条可能需要多个多边形,我们需要采用两点间差值来计算多边形,这里便不展开讨论,有兴趣的同学可以自己实现。

拖动与缩放

对于大数据量,一屏无法展示所有数据,所以我们需要对画布进行拖动与缩放。
画布的拖动我们可以转换思路为所要绘制的数据索引的变动。假定当前屏幕所绘制的数据索引为300到400的数据,当我们屏幕向左拖动100个数据单位长度时,我们需要绘制200-300的数据。
画布的缩放我们可以转变为当前屏幕需要绘制的数据大小。假定当前屏幕绘制数据100个,当我们放大屏幕时,我们所需要绘制的数据将减少,例如放大1倍,我们只需要绘制50个数据。
修改基本折线图层的代码,修改如下:

make() {
    this.childs.splice(0, this.childs.length);
    if(this.data.length === 0) {
      return;
    }
    // 计算X方向单位数值所占用的长度
    const xStep = this.width / this.showNum;
    this.xStep = xStep;
    // 数据偏移量
    let kLeft = Math.floor(this.position.x / xStep);
    // 数据的结束索引
    let end = this.data.length;
    let start = this.data.length - this.showNum;
    if (kLeft > 0 && this.data.length > kLeft + this.showNum) {
      // 向右移动数量与显示数量小于数据量, 结束索引等于数据量-偏移量
      end = this.data.length - kLeft;
      start = end - this.showNum;
    } else if (kLeft > 0) {
      // 向右移动数量超过数据量, 结束索引等于显示数量
      end = this.showNum;
      start = 0;
    } else {
      // 向左移动
      end = this.data.length - 1;
      start = end - this.showNum - kLeft;
    }
    this.start = start;
    this.end = end;
    let max = -99999999; // 数据最大值
    let min = 99999999; // 数据最小值
    //遍历数据,确定最大值与最小值
    for (let i = start; i < end; i++) {
      for (let key in this.data[i]) {
        if (key !== 'item') {
          if (max < this.data[i][key]) {
            max = this.data[i][key];
          }
          if (min > this.data[i][key]) {
            min = this.data[i][key];
          }
        }
      }
    }
    // 计算Y方向单位数值所占用的长度
    const yStep = this.height / (max - min);

    // 记录各线条所在的点
    const linePoints = {};
    // 遍历数据计算点的位置
    for (let i = start; i < end; i++) {
      const data = this.data[i];
      for(let key in data) {
        if (key !== 'item') {
          if (!linePoints[key]) {
            linePoints[key] = [];
          }
          linePoints[key].push(
            new Point(
              this.shiftLeft + (i - start) * xStep,
              this.position.y + (data[key] - min) * yStep
            )
          );
        }
      }
    }
    // 遍历所有的点数据,绘制相应曲线
    for (let key in linePoints) {
      let line = new MultiLine(this.canvas, {
        ...this.style,
        color: this.colors[key],
        position: linePoints[key][0],
      }, linePoints[key]);
      // 绘制面积图
      if (this.type === LineLayer.TYPE.FILL) {
        let polygon = new Polygon(this.canvas, {
          color: this.colors[key],
          type: Polygon.TYPE.FILL,
        }, [
          new Point(linePoints[key][0].x, 20),
          ...linePoints[key],
          new Point(linePoints[key][linePoints[key].length - 1].x, 20)
        ]) // 增加前后两个点使其闭合
        polygon.alpha = 0.4; // 修改透明度
        this.addChild(polygon);
      }
      this.addChild(line);
    }
    this.onMaked && this.onMaked(this, {
      start,
      end,
      xStep,
      yStep,
      yMax: max,
      yMin: min,
    });
 }

除了修改折线图层的绘制函数之外,我们还需要给线图层添加拖放事件与缩放事件,代码如下:

// 监听拖动事件
this.line.addEventListener(Event.EVENT_DRAG, (e) => {
  this.onChartDrag(e);
});
// 拖动结束事件
this.line.addEventListener(Event.EVENT_DRAG_END, (e) => {
  this.onChartDragEnd(e);
});
// 监听滚轮缩放
this.line.addEventListener(Event.EVENT_WHEEL, (e) => {
  this.onChartScale(e);
});

由上面代码可以看出,我们还添加了拖动结束事件监听,该监听用于拖动结束后的惯性效果,使得拖动更加自然。
拖动代码如下:

/**
* 图表缩放
* @param e
*/
onChartScale = (e) => {
    if(this.line.locked) {
      return;
    }
    if (e.nativeEvent.wheelDeltaY < 0) {
      if (this.line.showNum < this.line.data.length) {
        this.line.showNum = Math.round(this.line.showNum * 1.1);
        this.line.make();
        this.canvas.paint();
      }
    } else {
      if (this.line.showNum > 20) {
        this.line.showNum = Math.round(this.line.showNum * 0.9);
        this.line.make();
        this.canvas.paint();
      }
    }
}

onChartDrag = (e) => {
    if (this.line.locked) {
      return;
    }
    if (
      this.line.position.x + e.distanceX
      >= (this.line.data.length - this.line.showNum) * this.line.barWidth
    ) {
      // 移动的距离超过所有数据的长度, 设置为最大长度
      this.line.setPosition((this.line.data.length - this.line.showNum) * this.line.barWidth, this.line.position.y);
    } else if(this.line.position.x <= - this.line.width / 2) {
      // 至少保证K线数据占据半屏
      this.line.setPosition(-this.line.width / 2, this.line.position.y);
    }else {
      // 线平移相应的距离
      this.line.setPosition(this.line.position.x + e.distanceX, this.line.position.y);
    }
    this.line.make();
    this.canvas.paint();
}

// 移动结束的关心效果
onChartDragEnd = (e) => {
    if (this.barLayer.locked) {
      return;
    }
    const accelerate = e.speedX > 0 ? 3000 : -3000; // 加速度3000画布像素每秒
    const duration = Math.abs(e.speedX / accelerate);
    this.accelerateAction = new AccelerateAction(duration, {
      speedX: e.speedX,
      accelerateX: accelerate,
      beforeUpdate: (node, frame) => {
        if (node.position.x >= (this.barLayer.data.length - this.barLayer.showNum) * node.barWidth) {
          node.setPosition((this.barLayer.data.length - this.barLayer.showNum) * node.barWidth, node.position.y);
        } else if(node.position.x <= - this.barLayer.width / 2) {
          node.setPosition(- this.barLayer.width / 2, node.position.y);
    
        }
        node.make();
      }
    });
    this.barLayer.runAction(this.accelerateAction, (node, action) => {
      // 保证不移出画布
      if (node.position.x >= (this.barLayer.data.length - this.barLayer.showNum) * node.barWidth) {
        node.stopAction(action);
        node.setPosition((this.barLayer.data.length - this.barLayer.showNum) * node.barWidth, node.position.y);
      } else if (node.position.x <= - this.barLayer.width / 2) {
        node.stopAction(action);
        node.setPosition(- this.barLayer.width / 2, node.position.y);
      }
    });
}

拖动结束后我们使线图进行了一个加速度动作AccelerationAction,其源代码如下:

export default class AccelerateAction extends Action {
    constructor(duration,  option) {
        super(duration, 10);
        this.speedX = option.speedX || 0;
        this.speedY = option.speedY || 0;
        this.accelerateX = option.accelerateX || 0;
        this.accelerateY = option.accelerateY || 0;
        this.beforeUpdate = option.beforeUpdate;
    }

    /**
     * 更新函数
     * @param node
     * @param frame
     */
    update(node, frame) {
        if (this.allFrames === 0 && this.status !== Action.STATUS.RUNNING) {
            return;
        }
        const t = 1 / this.fps;
        // 计算X方向与Y方向的速度
        const vX = this.speedX + frame / this.fps * this.accelerateX / 1000;
        const vY = this.speedY + frame / this.fps * this.accelerateY / 1000;
        // 根据加速度公式计算移动距离
        const distX = vX * t + 0.5 * this.accelerateX * t * t;
        const distY = vY * t + 0.5 * this.accelerateY * t * t;
        if (!node.locked) {
            // 移动图层位置
            node.setPosition(node.position.x + distX, node.position.y + distY);
            this.beforeUpdate && this.beforeUpdate(node, frame);
            node.canvas.paint();
        }
    }
}

目录

【实现自己的可视化引擎01】认识Canvas
【实现自己的可视化框架引擎02】抽象图像元素
【实现自己的可视化引擎03】构建基础图元库
【实现自己的可视化引擎04】图像元素动画
【实现自己的可视化引擎05】交互与事件
【实现自己的可视化引擎06】折线图
【实现自己的可视化引擎07】柱状图
【实现自己的可视化引擎08】条形图
【实现自己的可视化引擎09】饼图
【实现自己的可视化引擎10】散点图
【实现自己的可视化引擎11】雷达图
【实现自己的可视化引擎12】K线图
【实现自己的可视化引擎13】仪表盘
【实现自己的可视化引擎14】地图
【实现自己的可视化引擎15】关系图