【实现自己的可视化引擎12】K线图

1,353 阅读7分钟

前言

烛形图主要用于金融领域里展示股票,期货等交易数据,按照时间维度分为日 K 线、周 K 线、月 K 线。展示的数据需要满足 K 线构成的四要素:即开盘价、收盘价、最高价、最低价。
我们首先看一下我们要实现的K线图的效果,如下图所示:

组件应用代码如下:

render() {
    return (
      <Candlestick
        className="chart"
        data={data}
        style={{
          color: '#C3C3C3', // 坐标字体颜色
          axisColor: '#999999', // 坐标网格颜色
          xFontSize: 20,    // 坐标X轴字体大小
          yFontSize: 20,    // 坐标Y轴字体大小
          enob: 4,          // Y轴有效位数
          positiveType: Candlestick.BAR_TYPE.STROKE,    // 阳线空心
        }}
      />)
}

从效果图上,我们可以把该组件拆分成坐标图层与K线图层。而K线图层又是由多根烛型图元依次摆列而成,所以在绘制K线层前,我们先来构建我们的烛型图元。

烛型图元

在效果图上,我们可以看出烛型图元有两种:线型空心(阳柱)与填充型(阴柱)。线型空心柱又可分为两条垂直直线加线框矩形,填充型柱可分为一条垂直线加填充矩形。因此,我们可以给出两种烛型的绘制流程如下:

  • 线型空心流程
  1. 绘制从最高价到较大值(收盘价或开盘价两者中的较大值)的直线;
  2. 依据收盘价与开盘价绘制矩形线框
  3. 绘制从最低价到较小值的直线(收盘价或开盘价两者中的较小值); 代码如下:
// 当前类型为线框
      // 绘制上部分线条开始
      painter.beginPath();
      // 移到最高点
      painter.moveTo(this.position.x + this.width / 2, this.position.y - (this.high - this.baseLine) * this.delta);
      // 移动到矩形上边界
      painter.lineTo(this.position.x + this.width / 2, this.position.y - (top - this.baseLine) * this.delta);
      painter.closePath();
      painter.stroke();
      // 上部分线条绘制结束
      // 绘制下部分线条开始
      painter.beginPath();
      // 移动到最低价位置
      painter.moveTo(this.position.x + this.width / 2, this.position.y - (this.low - this.baseLine) * this.delta);
      // 移动到矩形下边界
      painter.lineTo(this.position.x + this.width / 2, this.position.y - (bottom - this.baseLine) * this.delta);
      painter.closePath();
      painter.stroke();
      // 下部分线条结束
      // 绘制矩形部分
      painter.strokeRect(
        this.position.x + Math.round(0.1 * this.width),
        this.position.y - Math.round((top - this.baseLine) * this.delta),
        Math.round(this.width * 0.8),
        barHeight
      );
      // 绘制矩形结束
  • 填充型流程
  1. 绘制从最高价到最低价的直线
  2. 依据收盘价与开盘价绘制填充线框覆盖流程1中的直线 代码如下:
// 绘制最高最低价线型
  painter.beginPath();
  // 最高价
  painter.moveTo(this.position.x + this.width / 2, this.position.y - (this.high - this.baseLine) * this.delta);
  // 最低价
  painter.lineTo(this.position.x + this.width / 2, this.position.y - (this.low - this.baseLine) * this.delta);
  painter.closePath();
  painter.stroke();
  // 线型绘制结束
  // 绘制矩形部分
  painter.fillRect(
    this.position.x + Math.round(0.1 * this.width),
    this.position.y - Math.round((top - this.baseLine) * this.delta),
    Math.round(this.width * 0.8),
    barHeight
  );

完整代码如下:

export default class Bar extends Node {
  static BAR_TYPE = {
    FILL: 1,
    STROKE: 2,
  }

  constructor(canvas, style, data) {
    super(canvas, style);
    this.open = data.open; // 开盘价
    this.high = data.high; // 最高价
    this.low = data.low;   // 最低价
    this.close = data.close; // 收盘价
    this.datetime = data.datetime; // 时间线
    this.width = style.width || 20; // 烛型宽度
    this.delta = style.delta || 10; // 烛型单位数值所占用的像素
    this.baseLine = style.baseLine || 0; // 数值基线
    this.type = style.type || Bar.BAR_TYPE.FILL; // 柱形是否空心
  }

  set data(data) {
    this.open = data.open; // 开盘价
    this.high = data.high; // 最高价
    this.low = data.low;   // 最低价
    this.close = data.close; // 收盘价
    this.datetime = data.datetime; // 时间线
  }

  draw(painter) {
    // 计算矩形部分的高 |开盘-收盘| * 单位数值所占用的像素
    let barHeight = Math.round(Math.abs(this.close - this.open) * this.delta);
    // 防止开盘价与收盘价相等,高度为0,如十字线,默认最小2像素
    barHeight = barHeight > 2 ? barHeight : 3;
    painter.strokeStyle = this.color;
    painter.strokeWidth = '3px';
    painter.fillStyle = this.color;
    let top = Math.max(this.open, this.close);  // 矩形上边界值
    let bottom = Math.min(this.open, this.close);  // 矩形下边界值
    if (this.type === Bar.BAR_TYPE.STROKE) {
      // 当前类型为线框, 绘制线型空心,代码参考上述
      // ... 
    } else {
      // 当前类型为填充,绘制填充型,代码参考上述
      // ...
  }
}

K线图层

K线的样式比较简单,只有阳线阴线的类型与颜色以及缩放所需的单屏数量。K线图层的构造方法如下:

constructor(canvas, style, data = []) {
    super(canvas, style);
    this.data = data; // K线数据
    this.width = style.width || this.canvas.width;  // K线图层宽度
    this.height = style.height || this.canvas.height; // K线图层高度
    this.positiveColor = style.positiveColor || '#EE1100';  // 阳线颜色
    this.negativeColor = style.negativeColor || '#00C000';  // 阴线颜色
    this.positiveType = style.positiveType || Bar.BAR_TYPE.FILL; // 阳线类型
    this.negativeType = style.negativeType || Bar.BAR_TYPE.FILL; // 阴线类型
    this.showNum = style.showNum || 30; // 单屏展示数量
    this.locked = false; // 事件处理时,是否锁定标识
}

接下来我们分析一下构建K线的流程,

  • 根据图层位置计算数据索引的偏移量kLeft;
  • 根据偏移量kLeft计算需要绘制的数据(barStart, barEnd);
  • 计算绘制数据的最大值与最小值,从而计算单位数值的单位高度yDelta;
  • 遍历所需的绘制的数据,构建Bar图元并加入图层 依据流程编写代码:
make() {
    if (this.data.length === 0) {
      return;
    }
    // 移除所有子元素
    this.childs.splice(0, this.childs.length);
    // K线宽度
    const barWidth = this.width / this.showNum;
    this.barWidth = barWidth;
    // 数据偏移量
    let kLeft = Math.floor(this.position.x / barWidth);
    // 数据的结束索引
    let barEnd = this.data.length;
    let barStart = this.data.length - this.showNum;
    if (kLeft > 0 && this.data.length > kLeft + this.showNum) {
      // 向右移动数量与显示数量小于数据量, 结束索引等于数据量-偏移量
      barEnd = this.data.length - kLeft;
      barStart = barEnd - this.showNum;
    } else if (kLeft > 0) {
      // 向右移动数量超过数据量, 结束索引等于显示数量
      barEnd = this.showNum;
      barStart = 0;
    } else {
      // 向左移动
      barEnd = this.data.length - 1;
      barStart = barEnd - this.showNum - kLeft;
    }
    this.barStart = barStart;
    this.barEnd = barEnd;
    // 计算当前屏数据的最大值最小值
    let max = this.data[barStart].high;
    let min = this.data[barEnd].low;
    for (let i = barStart; i < barEnd; i++) {
      if (this.data[i].high > max) {
        max = this.data[i].high;
      }
      if (this.data[i].low < min) {
        min = this.data[i].low;
      }
    }
    this.max = max;
    this.min = min;
    // 根据最大值最小值计算单位数值的单位高度
    let yDelta = this.height / (max - min);
    /**柱图绘制开始**/
    for (let i = barStart; i < barEnd; i++) {
      // 阴线或阳线
      const isPositive = Number(this.data[i].close) > Number(this.data[i].open);
      // K线颜色
      const color = isPositive ? this.positiveColor : this.negativeColor;
      // 线图或填充图
      const type = isPositive ? this.positiveType : this.negativeType;
      const bar = new Bar(this.canvas, {
        width: barWidth,
        delta: yDelta,
        baseLine: min,
        color,
        type,
        // 柱状图位置
        position: new Point((i - barStart) * barWidth, this.position.y)
      }, this.data[i]);
      this.addChild(bar);
    }
    /**柱图绘制结束*/
    /**
     * 构建结束回调
     * **/
    this.onMaked && this.onMaked(this, {
      max,
      min,
      barWidth,
      yDelta,
      start: barStart,
      end: barEnd
    });
 }

React 封装

React封装需要DOM的挂载完成,所以我们在生命周期componentDidMount函数中构建我们的图层。K线组件我们分为坐标层与K线图层,K线图层构建完成后我们通知坐标层更新坐标。
坐标层配置代码如下:

// 坐标系基础配置
this.axisLayer = new AxisLayer(this.canvas, {
  yAxisType: AxisLayer.AxisType.NUMBER, // y轴为数值型
  xAxisType: AxisLayer.AxisType.LABEL,  // x轴时间为字符型
  xAxisGraduations: style.xAxis || 5,   // 网格5列
  yAxisGraduations: style.yAxis || 5,   // 网格5行
  xAxisPosition: AxisLayer.AxisPosition.BLOCK,  // X轴坐标不计算
  yAxisPosition: AxisLayer.AxisPosition.INNER,  // Y轴坐标计算
  yAxisRender: (value) => {
    const enob = style.enob || 2;
    return {
      text: Number(value).toFixed(enob),
      size: Number(style.yFontSize || 20),
      color: style.axisColor || '#999999',
      font: style.fontFamily || 'PingFang SC',
    };
  },
  xAxisRender: (label) => {
    const { value } = label;
    return {
      text: value,
      size: Number(style.xFontSize || 20),
      color: style.axisColor || '#999999',
      font: style.fontFamily || 'PingFang SC',
    };
  },
  color: style.color,
});

K线图层配置如下:

// K线坐标
this.barLayer = new KBarLayer(this.canvas, {
  height: (this.canvas.height - style.xFontSize * this.canvas.ratio) * 0.8, // 预留20%的空白空间
  positiveColor: style.positiveColor,
  negativeColor: style.negativeColor,
  positiveType: style.positiveType,
  negativeType: style.negativeType,
  position: new Point(0, style.xFontSize * this.canvas.ratio * 0.9 + 0.1 * this.canvas.height), // 预留的10% + 坐标的高度
}, data);

在K线构建完成的回调中,我们通知坐标层更新坐标数值,代码如下:

this.barLayer.onMaked = (layer, option) => {
  const { max, min, barWidth, yDelta, start, end } = option;
  // 计算坐标的最大值与最小值,加减预留部分的数值
  let yAxisMax = max + (this.canvas.height - style.xFontSize * this.canvas.ratio) * 0.1 / yDelta;
  let yAxisMin = min - (this.canvas.height - style.xFontSize * this.canvas.ratio) * 0.1 / yDelta;
  this.axisLayer.yAxisMin = yAxisMin;
  this.axisLayer.yAxisMax = yAxisMax;
  // 设置X轴时间的坐标
  let dataWidth = (end - start) * barWidth;
  // 假设间距为100个画布像素
  let dataNum = Math.round(dataWidth / 500);
  // 计算100画布像素索引距离
  let indexStep = Math.round(500 / barWidth);
  // x轴坐标数组
  let xAxisData = [];
  for (let i = 0; i < dataNum; i++) {
    const date = data[end - 1 - i * indexStep].time;
    xAxisData.unshift({
      value: date,
      position: new Point((end - 1 - i * indexStep - start) * barWidth, 0),
    });
  }
  this.axisLayer.xAxisLabels = xAxisData;
  this.axisLayer.make();
}

为K线拖动与缩放交互功能添加事件,代码如下:

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

至此K线图的流程已经结束了,在此基础上,我们可以通过叠加不同的图层丰富我们K线的功能。 目录

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