原生canvas实现标尺功能
简述
标尺常用于图形编辑器和低代码构筑平台上,最近因为项目需要需要实现一个标尺功能,要求有以下功能:
- 刻度
- 平移缩放
- 绘制上的图形同步缩放平移
综合考虑需求以及未来可能的变动后,决定用three绘制图形,canvas原生绘制标尺。而在查阅一些开源库时,发现很难有完美符合自己需求的库,很多都结合了Vue或者React封装成了组件,再番查阅后,了解到实现并不难,于是自己着手造轮子,准备做一个纯js库。
该功能以发布为npm包,欢迎尝试:
功能
- 刻度
- 平移
- 缩放
- 能够绑定同步
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两轴的刻度(网格)和刻度尺 ::: 首先要定义一些用到的变量,同时将配置项参数传进来
变量
- 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;
}
- grid 网格
// initRuler
// grid
this.grid = options.grid ?? true; // 是否绘制网格
this._gridSize = 50; // 网格像素大小
- 标尺 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 // 记录初始刻度值,此值不会改变
- 坐标
// 坐标原点,默认为0,0,即0,0会在屏幕中间
this.x = 0;
this.y = 0;
绘制
- 标尺背景
// 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轴标尺
- 计算刻度从何开始,从多少开始
_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()
- 绘制刻度和刻度数
先准备好“笔刷”
// 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;
}
- 执行绘制
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
以下是可以新增的功能点,后续按需求更新
- 鼠标对齐的浮标
- 小刻度
- 同一dom绘制
- 不同的缩放方式
总结
功能整体流程不复杂,但是我在实现缩放时卡了比较久,尽管我一边写这篇文章一边回顾代码流程,仍然觉得缩放的实现还可以优化,当前方式仅是实现了它,感兴趣的可以去尝试一下。
该功能以发布为npm包,欢迎尝试: