原生canvas实现标尺功能

1,037 阅读6分钟

原生canvas实现标尺功能

简述

标尺常用于图形编辑器和低代码构筑平台上,最近因为项目需要需要实现一个标尺功能,要求有以下功能:

  1. 刻度
  2. 平移缩放
  3. 绘制上的图形同步缩放平移

综合考虑需求以及未来可能的变动后,决定用three绘制图形,canvas原生绘制标尺。而在查阅一些开源库时,发现很难有完美符合自己需求的库,很多都结合了Vue或者React封装成了组件,再番查阅后,了解到实现并不难,于是自己着手造轮子,准备做一个纯js库。

该功能以发布为npm包,欢迎尝试:

  1. 使用文档
  2. 在线demo
  3. github地址
  4. 本篇博客(体验更好)

功能

  1. 刻度
  2. 平移
  3. 缩放
  4. 能够绑定同步Three

初始化

  <div>
    <canvas id="rulerBox"></canvas>
  </div>
class Ruler {
  constructor(id, options = {}) {
    this.dom = document.getElementById(id);
    if (!this.dom) throw new Error('请传入canvas的id')
    if (this.dom.getContext)
      this.ctx = this.dom.getContext('2d');
    this._initRuler(options);
    this._drawRuler();
  }
  _initRuler(options) {
  }
  reDraw(x, y, zoom) {
  }
  _drawRuler() {
  }
}
export default Ruler

首先创建一个canvas并设置id为 rulerBox ,并给它设置宽高,然后建立一个Ruler类,将id以及可配置项作为参数传入。

在constructor中获得 ctx

    this.dom = document.getElementById(id);
    if (!this.dom) throw new Error('请传入canvas的id')
    if (this.dom.getContext)
      this.ctx = this.dom.getContext('2d');

在这个类中,drawRuler是绘制具体的函数,initRuler会初始化类的一些变量,reDraw会调用drawRuler执行绘制,那么接下来是主要实现了

绘制标尺

::: tip 绘制标尺的思路是:先绘制标尺背景,然后分别绘制xy两轴的刻度(网格)和刻度尺 ::: 首先要定义一些用到的变量,同时将配置项参数传进来

变量

  1. canvas
  // initRuler
  // 如果canvas未设置宽高默认是300,150,这时会获取父元素的宽高,以此达到一定程度自适应
  if (this.dom.width === 300 && this.dom.height === 150) {
    this.dom.width = this.dom.parentElement.clientWidth;
    this.dom.height = this.dom.parentElement.clientHeight;
  }
  // 如果用户配置了宽和高,便使用配置的宽高,需要注意的是,单位是像素,传入值是number
  if (options.width && options.height) {
    this.dom.width = options.width;
    this.dom.height = options.height;
  }
  1. grid 网格
  // initRuler
  // grid
  this.grid = options.grid ?? true;  // 是否绘制网格
  this._gridSize = 50; // 网格像素大小
  1. 标尺 ruler
    // initRuler
    // grid
    this.rulerWidth = options.rulerWidth || 20; // 标尺的宽度(高度)
    this.rulerColor = options.rulerColor || "rgba(255,255,255,0.8)"; // 标尺背景颜色
    this.scaleColor = options.scaleColor || "black"; // 刻度颜色 | 网格颜色
    this.scaleHeight = options.scaleHeight || 6;  // 刻度线的长度(高度)
    this.topNumberPadding = options.topNumberPadding || 11; // x轴刻度数偏移量
    this.leftNumberPadding = options.leftNumberPadding || 2; // y轴刻度数偏移量
    this._scaleStepList = [1, 2, 5, 10, 25, 50, 100, 150, 300, 750, 1500]; // 刻度数列表
    this._scaleStep = 50; // 当前刻度数 必须是scaleStepList中的一个 标尺上的数值
    this._scaleStepOrigin = this._scaleStep // 记录初始刻度值,此值不会改变
  1. 坐标
    // 坐标原点,默认为0,0,即0,0会在屏幕中间
    this.x = 0;
    this.y = 0;

绘制

  1. 标尺背景
    // drawRuler
    // 获取标尺背景颜色,在容器上边和左边绘制出标尺背景
    this.ctx.fillStyle = this.rulerColor;
    this.ctx.fillRect(0, 0, this.dom.width, this.rulerWidth); // x轴标尺
    this.ctx.fillRect(0, 0, this.rulerWidth, this.dom.height); // y轴标尺
  1. 计算刻度从何开始,从多少开始
  _getStartAndEnd() {
    const gridSize = this._gridSize
    // 计算原点在屏幕上的位置
    // 举例,当原点在屏幕中间时,xy均为0
    // 那么屏幕坐标即为dom宽高一半的位置
    const screenX = this.x + this.dom.width / 2;
    const screenY = this.y + this.dom.height / 2;
    // 计算从屏幕左侧何处开始绘制
    const n = Math.floor(screenX / gridSize);
    let startX = screenX - n * gridSize;
    let startXNum = - n * this._scaleStep; // 左侧开始的刻度数
    // 最左侧如果和Y轴标尺重叠,则向右+1
    if (startX < this.rulerWidth) {
      startX += gridSize;
      startXNum += this._scaleStep;
    }
    // 计算从屏幕顶部从何处开始绘制
    const n2 = Math.floor(screenY / gridSize);
    let startY = screenY - n2 * gridSize;
    let startYNum = - n2 * this._scaleStep;
    // 同上
    if (startY < this.rulerWidth) {
      startY += gridSize;
      startYNum += this._scaleStep;
    }
    return { startX, startY, startXNum, startYNum }
  }
    // drawRuler
    const { startX, startY, startXNum, startYNum } = this._getStartAndEnd()
  1. 绘制刻度和刻度数

先准备好“笔刷”

    // drawRuler
    this.ctx.textAlign = 'center';
    const margin = this.rulerWidth - this.scaleHeight;
    let drawX = startX;
    let drawXNum = startXNum;
    let drawY = startY;
    let drawYNum = startYNum;
    this.ctx.strokeStyle = this.scaleColor;
    this.ctx.fillStyle = this.scaleColor;

x轴绘制

    // drawRuler
    while (drawX <= this.dom.width) {
      // 绘制刻度
      this.ctx.beginPath();
      this.ctx.moveTo(drawX, margin);
      // 是否绘制网格
      if (this.grid) {
        // 如果绘制网格,那么直接到容器底部
        this.ctx.lineTo(drawX, this.dom.height);
      } else {
        // 如果不绘制,则只绘制刻度的长度
        this.ctx.lineTo(drawX, margin + this.scaleHeight);
      }
      this.ctx.stroke();
      this.ctx.closePath(); // 结束刻度绘制
      // 绘制文本
      this.ctx.fillText(drawXNum, drawX, this.topNumberPadding);
      drawX += this._gridSize;
      drawXNum += this._scaleStep;
    }

y轴绘制

    // drawRuler
    while (drawY <= this.dom.height) {
      // 绘制刻度
      this.ctx.beginPath();
      this.ctx.moveTo(margin, drawY);
      if (this.grid) {
        this.ctx.lineTo(this.dom.width, drawY);
      } else {
        this.ctx.lineTo(margin + this.scaleHeight, drawY);
      }
      this.ctx.stroke();
      this.ctx.closePath(); // 结束刻度绘制
      this.ctx.save(); // 保存当前绘制结果
      // y轴文本绘制,由于要绘制纵向文本,需要先平移旋转,再重置矩阵
      this.ctx.translate(margin - this.leftNumberPadding, drawY);
      this.ctx.rotate(-Math.PI / 2);
      this.ctx.fillText(drawYNum, 0, 0);
      this.ctx.restore();
      drawY += this._gridSize;
      drawYNum += this._scaleStep;
    }
  1. 执行绘制
  reDraw() {
    this.ctx.clearRect(0, 0, this.dom.width, this.dom.height);
    this._drawRuler();
  }

至此,一个静态的标尺便绘制完成,接下来要实现标尺的平移,缩放

增加初始化内容

要想完成平移与缩放,首先我们应该注册相应的绑定事件,然后定义相应的变量

绑定事件

  // 绑定事件
  addListener() {
    this._events.mouseDown = this._mouseDownEvent.bind(this);
    this._events.mouseMove = this._mouseMoveEvent.bind(this);
    this._events.mouseUp = this._mouseUpEvent.bind(this);
    this._events.wheel = this._wheelEvent.bind(this);
    this.dom.addEventListener('mousedown', this._events.mouseDown);
    this.dom.addEventListener('mousemove', this._events.mouseMove);
    this.dom.addEventListener('mouseup', this._events.mouseUp);
    this.dom.addEventListener('wheel', this._events.wheel)
  }
  _mouseDownEvent(){}
  _mouseMoveEvent(){}
  _mouseUpEvent(){}
  _wheelEvent(){}

然后在constructor中调用addListener

    // 这里增加一个变量判断是否启用这些事件
    // 这是为了后面结合three增加的变量
    if (this.listener) {
      this._addListener();
    }

变量

    this.gridChange = options.gridChange || true; // 网格是否会根据缩放进行变化
    // 刻度列表/原始刻度 = 缩放比例
    // 用于缩放计算,其实可以直接使用刻度列表
    // 只是为了把zoom加入计算才这么做的
    this._zoomRatioList = [];
    for (let i = 0; i < this._scaleStepList.length; i++) {
      this._zoomRatioList.push(this._scaleStepList[i] / this._scaleStepOrigin);
    }
    if (this._scaleStepList.indexOf(this._scaleStep) < 0) {
      throw new Error('scaleStep must be one of _scaleStepList')
    }
    this._events = {
      mouseDown: '',
      mouseMove: '',
      mouseUp: '',
      wheel: ''
    }
    // event
    this._isDrag = false; // 是否拖拽中
    this.dragButton = options.dragButton ?? 0; //触发缩放的鼠标键,默认左键
    this._dragStartMouseCoord = []; // 记录开始拖拽时的鼠标坐标
    this.listener = options.listener ?? true;
    // scale
    this._zoomOrigin = 1; // 缩放原点
    this.zoom = 1; // 缩放级别
    this.zoomStep = options.zoomStep || 0.2; // 缩放阶梯

平移

::: tip 相对与缩放,平移考虑的事情较少,只需要将鼠标偏移量加到原点中既可 ::: 首先重构reDraw

  reDraw() { // [!code --]
  reDraw(x, y, zoom) { // [!code ++]
    this.zoom = zoom; // [!code ++]
    this.x = x; // [!code ++]
    this.y = y; // [!code ++]
    this.ctx.clearRect(0, 0, this.dom.width, this.dom.height);
    this._drawRuler();
  }

mouseDownEvent

  _mouseDownEvent(e) {
    e.preventDefault();
    if (!(e.button === this.dragButton)) return;
    this._isDrag = true;
    this._dragStartMouseCoord = [e.offsetX, e.offsetY];
  }

mouseMoveEvent

  _mouseMoveEvent(e) {
    e.preventDefault();
    if (!this._isDrag) return;
    const dx = e.offsetX - this._dragStartMouseCoord[0];
    const dy = e.offsetY - this._dragStartMouseCoord[1];
    if (this._isDrag) {
      const nX = this.x + dx;
      const nY = this.y + dy;
      this._dragStartMouseCoord = [e.offsetX, e.offsetY];
      this.reDraw(nX, nY, this.zoom);
    }
  }

mouseUpEvent

  _mouseUpEvent(e) {
    this._isDrag = false;
  }

缩放

::: tip 缩放这里比较麻烦,首先要考虑刻度数的变化和网格大小的变化,其次是朝什么方向缩放,从而移动中心点 :::

wheelEvent

  _wheelEvent(e) {
    // 设置缩放
    if (e.deltaY > 0) {
      // 缩小
      this.zoom -= this.zoomStep
      // this.zoom = this.zoom * (this._zoomOrigin - this.zoomStep);
      // this._gridSize = this._gridSize * (this._zoomOrigin - this.zoomStep);

    } else {
      // 放大
      this.zoom += this.zoomStep;
      // this.zoom = this.zoom / (this._zoomOrigin - this.zoomStep);
      // this._gridSize = this._gridSize / (this._zoomOrigin - this.zoomStep);
    }
    // 避免double类型的多余浮点
    this.zoom = parseFloat(this.zoom.toFixed(2))
    if (this.zoom <= 0) this.zoom = this.zoomStep
    // 计算缩放后的格网大小
    this._gridSize = this.zoom * this._scaleStepOrigin;

    // 平移
    const centerX = this.dom.width / 2;
    const centerY = this.dom.height / 2;
    const dx = e.offsetX - centerX;
    const dy = e.offsetY - centerY;
    const nx = this.x + dx * this.zoomStep;
    const ny = this.y + dy * this.zoomStep;
    // 网格是否发生变化
    if (this.gridChange) {
      const step = this.getScaleStep();
      this._scaleStep = step;
      this._gridSize = this._scaleStep * this.zoom;
    }
    this.reDraw(nx, ny, this.zoom);
  }

网格变化

  // 该方法是为了找到当前缩放层级匹配的刻度数
  _getScaleStep() {
    const origin = this._scaleStepList.indexOf(this._scaleStepOrigin);
    for (let i = 1; i < this._zoomRatioList.length; i++) {
      if (this.zoom >= this._zoomRatioList[i - 1] && this.zoom < this._zoomRatioList[i]) {
        const left = this.zoom - this._zoomRatioList[i - 1];
        const right = this._zoomRatioList[i] - this.zoom;
        let index = origin;
        if (left < right) {
          index = origin - (i - 1 - origin)
        } else {
          index = origin - (i - origin);
        }
        if (index > this._zoomRatioList.length - 1) index = this._zoomRatioList.length - 1
        if (index < 0) index = 0;
        return this._scaleStepList[index];
      }
    }
    return this._scaleStep

以上便实现了整体标尺的功能,接下来是一些扩展性功能

与three.js同步

  bindThreeCamera(camera, controls, origin) {
    this._isBindThree = true;
    this.controls = controls;
    this._events.three = this._threeEvent.bind(this, camera, origin);
    controls.addEventListener('change', this._events.three);
  }
  _threeEvent(camera, origin) {
    const coords = origin.project(camera);
    const halfWidth = this.dom.width / 2
    const halfHeight = this.dom.height / 2
    const originX = -(coords.x * halfWidth + halfWidth)
    const originY = -(coords.y * halfHeight + halfHeight)
    this.zoom = camera.zoom
    if (this.zoom <= 0) this.zoom = this.zoomStep
    this._gridSize = this.zoom * this._scaleStepOrigin;
    if (this.gridChange) {
      const step = this._getScaleStep();
      this._scaleStep = step;
      this._gridSize = this._scaleStep * this.zoom;
    }
    this.reDraw(-originX - halfWidth, originY + halfHeight, this.zoom)
  }

TODO

以下是可以新增的功能点,后续按需求更新

  1. 鼠标对齐的浮标
  2. 小刻度
  3. 同一dom绘制
  4. 不同的缩放方式

总结

功能整体流程不复杂,但是我在实现缩放时卡了比较久,尽管我一边写这篇文章一边回顾代码流程,仍然觉得缩放的实现还可以优化,当前方式仅是实现了它,感兴趣的可以去尝试一下。

该功能以发布为npm包,欢迎尝试:

  1. 使用文档
  2. 在线demo
  3. github地址
  4. 本篇博客(体验更好)