Canvas定制组件——事件线之折线图的绘制

2,225 阅读8分钟

我报名参加金石计划一期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

项目背景

产品提了个需求,要看各种事件对某个指标的影响,要前端开发一个事件和折线图联动的组件,给了个设计稿大概就是上图的样子。

然后根据需求,使用Canvas定制了这样一个组件,并且开源出去,发布到npm上。

前面已经产出两篇关于事件线的博客:一篇介绍npm组件库项目搭建和文档编写;第二篇介绍了事件的基础绘制;感兴趣的朋友可以先阅读这前两篇。

今天这一篇主要讲折线的绘制,后续还会出一篇将事件线动画和交互,敬请期待。

需求分析

Canvas要实现一个折线图, 需要绘制X轴以及刻度,绘制Y轴以及辅助线,然后绘制折线,最后再绘制上交互事件。上一篇已经绘制过X轴以及刻度,今天就略过了,有兴趣的朋友可以看Canvas定制组件——事件线之事件的绘制

其中Y轴的绘制主要是分析值域最大和最小以及等比例分割; 辅助线绘制则是虚线绘制;另外绘制折线则要考虑左右轴多折线的问题; 当然既然多折线还要考虑图例的绘制; 今天就暂时不讲交互事件,后面将和事件的交互一起讲解,欢迎大家催更

开始绘制

对于趋势折线的绘制,今天就主要介绍下面几个方面:

  • Y轴以及辅助线;
  • 绘制折线;
  • 图例的绘制;

Y轴以及辅助线绘制

要绘制多折线左右双轴的折线图,先要分别分析出左Y轴和右Y轴的最大最小值,从而进一步确定两Y轴的坐标值域,然后就需要把数据源按折线类型seriesField拆分开来,分别绘制:

export const analysisLineData = (lines: any=[], { xField, yField, axisY }: any = {}) => {
  return lines?.reduce(({ minDt, maxDt, minValue, maxValue, types }: any, item: any) => {
    return {
      minDt: momentMin(minDt || item?.[xField], item?.[xField]),
      maxDt: momentMax(maxDt || 0, item?.[xField]),
      minValue: Math.min(minValue === 0 ? 0 : minValue || item?.[yField], item?.[yField]),
      maxValue: Math.max(maxValue || 0, item?.[yField]),
      types: new Set([...(types || []), item?.[axisY?.seriesField]]),
    };
  }, {});
};

analysisLineData方法可以分析出左轴折线或右轴折线在x轴时间最大值和最小值,以及y轴最大值和最小值,同时统计出折线类型数组(去重)。时间最大最小值用于绘制X轴,值域最大最小则用来绘制Y轴,折线类型数组则在最后按照折线类型分组绘制折线

在拿到y轴折线最大值和最小值之后,还要进一步处理下,因为通常我们绘制的Y轴的值是整十整百的,132这种具体值是不绘制在y轴坐标轴上的,所以需要对值域进行向上或向下取整十整百;当然1%这样的比例,可以先放大100倍,然后取整后再除以100得出,具体方法如下:

// 向上取整十,整百,整千,整万 ceil向上,floor向下
export const turnNumber = (value: number, method: 'ceil' | 'floor', rate = 1) => {
  if (value === 0) return 0;
  let plus = 1;
  let fun = method;
  if (value < 0) {
    plus = -1;
    fun = method === 'ceil' ? 'floor' : 'ceil';
  }
  let absValue = Math.abs(value * rate);
  let bite = 0;
  if (absValue < 10) {
    return fun === 'ceil' ? plus * 10 / rate : 0;
  }
  while (absValue >= 10) {
    absValue /= 10;
    bite += 1;
  }
  return (plus * Math[fun](absValue) * Math.pow(10, bite)) / rate;
};

turnNumber这个方法兼容向上和向下取整,同时兼容百分比和一般数值,当然也兼容了正数和负数的差异。

  • 0直接返回;
  • 正数统计是10的多少倍
  • 负数先存储plus = -1作为符号,然后把向上ceil和向下floor方法颠倒,取负数绝对值统计10的倍数,最后再乘以plus符号恢复正负;
  • 绝对值小于10时,向上取整直接返回10,向下取整直接返回0,当然要加上正负号;
  • 对于展示百分比的小数,则先rate传值100,先乘以100进行向上/下取整,最后再除以100即可。

image.png 这样计算得出Y轴最大最小值之后,接下来就是按照Y轴辅助线的个数等分值域区间,并绘制出Y轴刻度值和辅助线:

// 绘制水平线
export const drawHorizontalLine = (
  ctx: any,
  offsetX = 0,
  offsetYs: number[] = [],
  config: any = {},
) => {
  const {
    axisXData,
    lineWidth = 1,
    strokeStyle = '#999',
    isDashLine = false,
    dash = [5, 5],
    offset = 0,
    scaleSpace,
    visibleXLeft,
    visibleXRight,
  } = config;
  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = strokeStyle;

  // 性能优化:可以只绘制可见部分
  const startX = Math.max(offsetX, visibleXLeft);
  const endX = Math.min(offsetX + (axisXData.length - 1) * scaleSpace, visibleXRight);

  offsetYs.forEach((offsetY, i) => {
    ctx.beginPath();
    ctx.lineDashOffset = 0;
    if (isDashLine && i !== 0) {
      ctx.setLineDash(dash);
      ctx.lineDashOffset = offset;
    }

    ctx.moveTo(startX, offsetY);
    ctx.lineTo(endX, offsetY);
    ctx.stroke();
    ctx.setLineDash([]);
  });
  ctx.lineDashOffset = 0;
};

当然这里也要处理辅助线的起始Y坐标数组offsetYs,可以根据辅助线的个数和起点坐标计算得出,就不再赘述了。还有就是辅助线只绘制可见区域就可以了,当然这属于优化部分了,前期可以不用考虑的。

绘制Y轴刻度文本也差不多,在和辅助线起点相同的起始坐标向左偏移文本的宽度大小开始绘制文本就可以实现,具体代码如下:

export const drawYAxisText = (ctx: any, offsetX = 0, offsetYs: number[] = [], config: any = {}) => {
  const { axisYMin, axisYMax, font, formatter, width, space = 4, fillStyle = '#999' } = config;
  const every = (axisYMax - axisYMin) / (offsetYs.length - 1);
  offsetYs.forEach((offsetY, i) => {
    const text = formatter(axisYMin + every * i);
    const textWidth = ctx.measureText(text).width;
    ctx.fillStyle = fillStyle;
    ctx.font = font;
    ctx.fillText(text, offsetX - (width - space || textWidth + space), offsetY, 120); // (len > 2 ? len * 12 - 8 : 26)
  });
};

上面的drawYAxisText方法先均分了值域区间,然后按照起始坐标计算偏移量进行绘制即可。

左右两Y轴的绘制方法基本相同,具体细节上的差异大家在绘制时也容易想到,就不再多说了。

折线的绘制

折线的绘制大致可以区分单条折线的绘制一组折线的绘制多组折线的绘制左右轴折线的绘制这样一个层层递进的关系;一条折线的绘制实现了,循环一下就是一组折线的绘制,再按分组循环就是分组折线,左右也大抵如此。这里就只示例一组折线的绘制方法,相信聪明的大家一定可以举一反三,绘制出左右双轴分组折线。

export const drawChartLines = (
  ctx: any,
  axisXStart: string,
  zeroX: number,
  zeroY: number,
  list: any,
  config?: any,
) => {
  const {
    strokeStyle = '#1890ff',
    scaleSpace,
    axisYMin,
    axisYMax,
    dashLineSpace = 50,
    dashLineCount = 5,
    showTooltip,
    dtKey = 'dt',
    valueKey = 'value',
    lineStyle,
    visibleXLeft,
    visibleXRight,
  } = config || {};
  const every = (axisYMax - axisYMin) / (dashLineCount - 1);
  const { dash = [], offset = 0, lineWidth = 2 } = lineStyle || {};
  ctx.setLineDash(dash);
  ctx.lineDashOffset = offset;
  ctx.strokeStyle = strokeStyle;
  ctx.lineWidth = lineWidth;
  ctx.beginPath();
  list
    .sort((a: any, b: any) => a?.[dtKey] - b?.[dtKey])
    .reduce(({ prevX = visibleXLeft }: any = {}, item: any) => {
      const len = moment(item?.[dtKey]).diff(axisXStart, 'days');
      const pointX = zeroX + len * scaleSpace;
      const pointY = zeroY - ((item?.[valueKey] - axisYMin) / every) * dashLineSpace;
      ctx.lineTo(pointX, pointY);
      return { prevX: pointX };
    }, {});
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.lineDashOffset = 0;
};

moveTo这里可以忽略。

drawChartLines 方法,说简单点就是不停地调用lineTo方法绘制线段,其中核心的就是根据xy值的找到对应canvas的坐标xy,lineTo连线j;x轴的日期的计算前面已经说过,大致就是计算当前日期与起始日期的的天数差乘以刻度宽加上起始x坐标就可以计算得出;y轴的坐标计算也大抵相同:计算当前值和Y轴起始值之间的差值乘以辅助线Y值均分值,再乘以辅助线之间的间距,最后加上起始y轴坐标值。

一言以蔽之就是:计算当前值与X/Y轴起始之间的比例差✖️单位值间距可以得出具体长度或高度值,最后加上具体起始坐标值就可以了。

图例的绘制

image.png

图例的绘制需要考虑折线类型以及相关颜色值,然后循环依次进行绘制即可,需要考虑绘制的地点——左右上下,考虑地点是否可以配置,正着绘制还是倒着绘制,文本绘制基线textBaseline和图形在同一水平等问题。

export const drawLegend = (
  ctx: any,
  offsetX: number,
  offsetY: number,
  { legend, font, lineColor = [], types = [] }: any,
) => {
  let prevLength = 0;
  // const [left, right] = lineColor;
  const legendLabel = types?.length > 0 ? types : legend.label || [];
  legendLabel.reverse().forEach((label: string, i: number) => {
    const width = ctx.measureText(label).width + legend.labelSpace;
    const offX = offsetX - prevLength - width;
    ctx.fillStyle = lineColor[legendLabel.length - 1 - i];
    ctx.fillRect(
      offX - legend.height - legend.labelRectSpace,
      offsetY + legend.marginTop,
      legend.height,
      legend.height,
    );
    ctx.fillStyle = legend.color;
    ctx.font = font;
    ctx.textBaseline = 'top';
    ctx.fillText(label, offX, offsetY + legend.marginTop);
    prevLength = prevLength + width + legend.height + legend.labelRectSpace;
  });
};

由于已经预设图例放在右下角,所以对折线类型和颜色数组进行了倒着绘制,是出于从右到左绘制可以尽可能美观。

否则需要提前计算所有图例加文本的宽度,得到X轴方向的偏移量才能精确地把图例绘制在右下角。

总结

折线的绘制,需要基本图像的绘制,也需要考虑左右双轴分组折线,样式和API的可配置等等方面,可以说是个复杂的工程;每增加一种功能就是对已有实现方式的挑战,可能时不时要推到重来才能实现新的需求。

这不,事件线最新设计稿趋势图部分绘制双轴多折线多柱混合图......

我的心就像这六月的天,就像十五个个吊桶打水,就像...

可谁让咱是宝藏boy呢?接着加班,接着coding...

image.png