小程序绘制天气折线图

1,729 阅读2分钟

折线

效果图

image.png

自定义组件 line-chart
<canvas type="2d" id="line" class="line-class" style="width:{{width}}px;height:{{height}}px" />
Component({
  externalClasses: ['line-class'],
  properties: {
    width: String,
    height: String,
    data: Array,
  },
  observers: {
    width() {
      // 这里监听 width 变化重绘 canvas
      // 动态传入 width 好像只能这样了..
      const query = this.createSelectorQuery();
      query
        .select('#line')
        .fields({ node: true, size: true })
        .exec(res => {
          const canvas = res[0].node;
          const ctx = canvas.getContext('2d');
          const width = res[0].width; // 画布宽度
          const height = res[0].height; // 画布高度

          console.log(`宽度: ${width}, 高度: ${height}`);

          const dpr = wx.getSystemInfoSync().pixelRatio;
          canvas.width = width * dpr;
          canvas.height = height * dpr;
          ctx.scale(dpr, dpr);

          // 开始绘图
          this.drawLine(ctx, width, height, this.data.data);
        });
    },
  },
  methods: {
    drawLine(ctx, width, height, data) {
      const Max = Math.max(...data);
      const Min = Math.min(...data);

      // 把 canvas 的宽度, 高度按一定规则平分
      const startX = width / (data.length * 2), // 起始点的横坐标 X
        baseY = height * 0.9, // 基线纵坐标 Y
        diffX = width / data.length,
        diffY = (height * 0.7) / (Max - Min); // 高度预留 0.2 写温度

      ctx.beginPath();
      ctx.textAlign = 'center';
      ctx.font = '13px Microsoft YaHei';
      ctx.lineWidth = 2;
      ctx.strokeStyle = '#ABDCFF';

      // 画折线图的线
      data.forEach((item, index) => {
        const x = startX + diffX * index,
          y = baseY - (item - Min) * diffY;

        ctx.fillText(`${item}°`, x, y - 10);
        ctx.lineTo(x, y);
      });
      ctx.stroke();

      // 画折线图背景
      ctx.lineTo(startX + (data.length - 1) * diffX, baseY); // 基线终点
      ctx.lineTo(startX, baseY); // 基线起点
      const lingrad = ctx.createLinearGradient(0, 0, 0, height * 0.7);
      lingrad.addColorStop(0, 'rgba(255,255,255,0.9)');
      lingrad.addColorStop(1, 'rgba(171,220,255,0)');
      ctx.fillStyle = lingrad;
      ctx.fill();

      // 画折线图上的小圆点
      ctx.beginPath();
      data.forEach((item, index) => {
        const x = startX + diffX * index,
          y = baseY - (item - Min) * diffY;

        ctx.moveTo(x, y);
        ctx.arc(x, y, 3, 0, 2 * Math.PI);
      });
      ctx.fillStyle = '#0396FF';
      ctx.fill();
    },
  },
});

data 就是温度数组,如 [1, 2, ...]

因为不知道温度数值有多少个,因此这里的 width 动态传入

有个小问题,就是宽度过大的话真机不会显示...

 // 获取 scroll-view 的总宽度
 wx.createSelectorQuery()
      .select('.hourly')
      .boundingClientRect(rect => {
        this.setData({
          scrollWidth: rect.right - rect.left,
        });
      })
      .exec();
<view class="title">小时概述</view>
<scroll-view scroll-x scroll-y class="scroll" show-scrollbar="{{false}}" enhanced="{{true}}">
    <view class="hourly">
      <view wx:for="{{time}}" wx:key="index">{{item}}</view>
    </view>
    <line-chart line-class="line" width="{{scrollWidth}}" height="100" data="{{temp}}" />
</scroll-view>

这里写 scroll-x 和 scroll-y,要不会出现绝对定位偏移的问题,也不知道为什么 😭

image.png

.scroll {
  position: relative;
  height: 150px;
  width: 100%;
}

.hourly {
  display: flex;
  height: 150px;
  position: absolute;
  top: 0;
}

.hourly > view {
  min-width: 3.5em;
  text-align: center;
}

.line { // 折线图绝对定位到底部
  position: absolute;
  bottom: 0;
}

这里使用绝对定位其实是想模拟墨迹天气这种折线图和每一天在一个块内的效果,所以 hourly 要和 scroll-view 等高,canvas 需要定位一下

主要是不知道墨迹天气怎么实现的,只能暂时这样

image.png

三阶贝塞尔曲线

效果图

image.png

emmm,好像并不怎么圆滑 🤣

计算控制点

首先写一个点类

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

Canvas贝塞尔曲线绘制工具 (karlew.com)

通过上面这个网站可以知道三阶贝塞尔曲线各个参数的意义

image.png

也就是使用 bezierCurveTo 的时候最后一个点是下一个点,前两个是控制点

控制点的计算参考: 贝塞尔曲线控制点确定的方法 - 百度文库

浓缩一下就是

image.png

这里的 a 和 b 可以是任意正数

因此定义一个计算某点的控制点 A 和 B 的方法

/**
 * 计算当前点的贝塞尔曲线控制点
 * @param {Point} previousPoint: 前一个点
 * @param {Point} currentPoint: 当前点
 * @param {Point} nextPoint1: 下一个点
 * @param {Point} nextPoint2: 下下个点
 * @param {Number} scale: 系数
 */
calcBezierControlPoints(
  previousPoint,
  currentPoint,
  nextPoint1,
  nextPoint2,
  scale = 0.25
) {
  let x = currentPoint.x + scale * (nextPoint1.x - previousPoint.x);
  let y = currentPoint.y + scale * (nextPoint1.y - previousPoint.y);

  const controlPointA = new Point(x, y); // 控制点 A

  x = nextPoint1.x - scale * (nextPoint2.x - currentPoint.x);
  y = nextPoint1.y - scale * (nextPoint2.y - currentPoint.y);

  const controlPointB = new Point(x, y); // 控制点 B

  return { controlPointA, controlPointB };
}

这里 scale 就是 a 和 b,不过将它们的取值相等

但是第一个点没有 previousPoint,倒数第二个点没有 nextPoint2

因此当点是第一个的时候,使用 currentPoint 代替 previousPoint

当倒数第二个点的时候,使用 nextPoint1 代替 nextPoint2

image.png

至于最后一个点,不需要做任何事,因为 bezierCurveTo 第三个参数就是下一个点,只需要提供坐标就能连起来,不需要计算控制点

因此绘制三阶贝塞尔曲线的方法:

/**
 * 绘制贝塞尔曲线
 * ctx.bezierCurveTo(控制点1, 控制点2, 当前点);
 */
drawBezierLine(ctx, data, options) {
  const { startX, diffX, baseY, diffY, Min } = options;

  ctx.beginPath();
  // 先移动到第一个点
  ctx.moveTo(startX, baseY - (data[0] - Min) * diffY);

  data.forEach((e, i) => {
    let curPoint, prePoint, nextPoint1, nextPoint2, x, y;

    // 当前点
    x = startX + diffX * i;
    y = baseY - (e - Min) * diffY;
    curPoint = new Point(x, y);

    // 前一个点
    x = startX + diffX * (i - 1);
    y = baseY - (data[i - 1] - Min) * diffY;
    prePoint = new Point(x, y);

    // 下一个点
    x = startX + diffX * (i + 1);
    y = baseY - (data[i + 1] - Min) * diffY;
    nextPoint1 = new Point(x, y);

    // 下下个点
    x = startX + diffX * (i + 2);
    y = baseY - (data[i + 2] - Min) * diffY;
    nextPoint2 = new Point(x, y);

    if (i === 0) {
      // 如果是第一个点, 则前一个点用当前点代替
      prePoint = curPoint;
    } else if (i === data.length - 2) {
      // 如果是倒数第二个点, 则下下个点用下一个点代替
      nextPoint2 = nextPoint1;
    } else if (i === data.length - 1) {
      // 最后一个点直接退出
      return;
    }

    const { controlPointA, controlPointB } = this.calcBezierControlPoints(
      prePoint,
      curPoint,
      nextPoint1,
      nextPoint2
    );

    ctx.bezierCurveTo(
      controlPointA.x,
      controlPointA.y,
      controlPointB.x,
      controlPointB.y,
      nextPoint1.x,
      nextPoint1.y
    );
  });

  ctx.stroke();
},

封装成一个类

直接上代码...

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
}

export class Line {
  constructor(chart) {
    this.canvas = chart.node;
    this.ctx = this.canvas.getContext('2d');
    this.width = chart.width;
    this.height = chart.height;

    const dpr = wx.getSystemInfoSync().pixelRatio;
    this.canvas.width = this.width * dpr;
    this.canvas.height = this.height * dpr;
    this.ctx.scale(dpr, dpr);
  }

  /**
   * 初始化图表
   * @param {Object} option
   * option = {
   *    textStyle: {
   *       color: '#fff',
   *       fontSize: '10px',
   *       fontFamily: 'Microsoft YaHei',
   *       textAlign: 'center',
   *    },
   *    series: [
   *      {
   *        data: [820, 932, 901, 934, 1290, 1330, 1320],
   *        smooth: true,
   *        label: {
   *           show: true,
   *           formatter: '{d}°'
   *        },
   *        lineStyle: {
   *           width: 3,
   *           color: '#abdcffa',
   *           backgroundColor: 'rgba(171,220,255,0.9)',
   *        },
   *      }, { .. }
   *    ]
   * };
   */
  init(options) {
    this.option = options;
    const series = options.series;

    let data = [],
      maxLength = 0;
    
    series.forEach(el => {
      if (el.data instanceof Array) {
        data.push(el.data);

        // 获取每组数据长度最大的那个长度
        if (maxLength < el.data.length) {
          maxLength = el.data.length;
        }
      }
    });

    this.Max = Math.max(...data.flat()); // 最大值
    this.Min = Math.min(...data.flat()); // 最小值

    // 把 canvas 的宽度, 高度按一定规则平分
    this.startX = this.width / (maxLength * 2); // 起始点的横坐标 X
    this.baseY = this.height * 0.9; // 基线纵坐标 Y
    this.diffX = this.width / maxLength; // 每个元素的宽度差
    this.diffY = (this.height * 0.7) / (this.Max - this.Min); // 高度预留 0.2 写标签

    const textStyle = options.textStyle;
    this.ctx.textAlign = textStyle.textAlign || 'center';
    this.ctx.font = `${textStyle.fontSize || '14px'} ${
      textStyle.fontFamily || 'monospace'
    }`;

    // 开始绘图
    series.forEach(el => {
      if (el.data instanceof Array) {
        if (el.smooth) {
          // 曲线图
          const path = this.createBezierLine(el);
          this.drawLine(path, el);
        } else {
          // 折线图
          const path = this.createBrokenLine(el);
          this.drawLine(path, el);
        }
      }
    });
  }

  // 绘制
  drawLine(path, el) {
    const { data, label, lineStyle } = el;

    this.drawBackground(path, el); // 背景

    this.ctx.beginPath();

    this.ctx.lineWidth = lineStyle?.width || 3;
    this.ctx.strokeStyle = lineStyle?.color || '#abdcff';

    this.ctx.stroke(path);

    this.drawLabel(data, label); // 标签
    this.drawDots(data); // 小圆点
  }

  // 绘制标签
  drawLabel(data, label) {
    this.ctx.fillStyle = '#000'; // 标签默认黑色

    if (label?.show && label?.formatter) {
      // 存在 formatter 时
      data.forEach((e, i) => {
        const x = this.startX + this.diffX * i,
          y = this.baseY - (e - this.Min) * this.diffY;

        this.ctx.fillText(label.formatter.replace(/\{d\}/i, e), x, y - 10);
      });
    } else if (label?.show) {
      // 不存在 formmater 时
      data.forEach((e, i) => {
        const x = this.startX + this.diffX * i,
          y = this.baseY - (e - this.Min) * this.diffY;

        this.ctx.fillText(e, x, y - 10);
      });
    }
  }

  // 画折线图小圆点
  drawDots(data) {
    this.ctx.beginPath();

    data.forEach((e, i) => {
      const x = this.startX + this.diffX * i,
        y = this.baseY - (e - this.Min) * this.diffY;

      this.ctx.moveTo(x, y);
      this.ctx.arc(x, y, 3, 0, 2 * Math.PI);
    });
    this.ctx.fillStyle = this.ctx.strokeStyle;
    this.ctx.fill();
  }

  // 画折线图背景
  drawBackground(path, el) {
    const { lineStyle } = el;

    if (typeof lineStyle?.backgroundColor !== 'undefined') {
      const { data } = el;
      const path_ = new Path2D(path);

      path_.lineTo(this.startX + (data.length - 1) * this.diffX, this.baseY); // 基线终点
      path_.lineTo(this.startX, this.baseY); // 基线起点

      const lingrad = this.ctx.createLinearGradient(0, 0, 0, this.height);
      lingrad.addColorStop(0, lineStyle.backgroundColor);
      lingrad.addColorStop(1, 'rgba(255,255,255,0)');
      this.ctx.fillStyle = lingrad;

      this.ctx.fill(path_);
    }
  }

  /**
   * 计算当前点的贝塞尔曲线控制点
   * @param {Point} previousPoint: 前一个点
   * @param {Point} currentPoint: 当前点
   * @param {Point} nextPoint1: 下一个点
   * @param {Point} nextPoint2: 下下个点
   * @param {Number} scale: 系数
   */
  calcBezierControlPoints(
    previousPoint,
    currentPoint,
    nextPoint1,
    nextPoint2,
    scale = 0.25
  ) {
    let x = currentPoint.x + scale * (nextPoint1.x - previousPoint.x);
    let y = currentPoint.y + scale * (nextPoint1.y - previousPoint.y);

    const controlPointA = new Point(x, y); // 控制点 A

    x = nextPoint1.x - scale * (nextPoint2.x - currentPoint.x);
    y = nextPoint1.y - scale * (nextPoint2.y - currentPoint.y);

    const controlPointB = new Point(x, y);

    return { controlPointA, controlPointB };
  }

  /**
   * 创建贝塞尔曲线路径
   * @param {*} ctx
   * @param {*} data
   * @param {*} options
   */
  createBezierLine(el) {
    const { data } = el;
    const path = new Path2D();
    const { startX, baseY, Min, diffY, diffX } = this;

    path.moveTo(this.startX, this.baseY - (data[0] - this.Min) * this.diffY);

    data.forEach((e, i) => {
      let curPoint, prePoint, nextPoint1, nextPoint2, x, y;

      // 当前点
      x = startX + diffX * i;
      y = baseY - (e - Min) * diffY;
      curPoint = new Point(x, y);

      // 前一个点
      x = startX + diffX * (i - 1);
      y = baseY - (data[i - 1] - Min) * diffY;
      prePoint = new Point(x, y);

      // 下一个点
      x = startX + diffX * (i + 1);
      y = baseY - (data[i + 1] - Min) * diffY;
      nextPoint1 = new Point(x, y);

      // 下下个点
      x = startX + diffX * (i + 2);
      y = baseY - (data[i + 2] - Min) * diffY;
      nextPoint2 = new Point(x, y);

      if (i === 0) {
        // 如果是第一个点, 则前一个点用当前点代替
        prePoint = curPoint;
      } else if (i === data.length - 2) {
        // 如果是倒数第二个点, 则下下个点用下一个点代替
        nextPoint2 = nextPoint1;
      } else if (i === data.length - 1) {
        // 最后一个点直接退出
        return;
      }

      const { controlPointA, controlPointB } = this.calcBezierControlPoints(
        prePoint,
        curPoint,
        nextPoint1,
        nextPoint2
      );

      path.bezierCurveTo(
        controlPointA.x,
        controlPointA.y,
        controlPointB.x,
        controlPointB.y,
        nextPoint1.x,
        nextPoint1.y
      );
    });

    return path;
  }

  // 创建折线路径
  createBrokenLine(el) {
    const { data } = el;
    const path = new Path2D();
    data.forEach((e, i) => {
      const x = this.startX + this.diffX * i,
        y = this.baseY - (e - this.Min) * this.diffY;

      path.lineTo(x, y);
    });

    return path;
  }
}