前言

不说废话,我们直入主题。先来看看读了这篇文章你将得到什么,就是下面这个东西啦👇(是不是很清晰很顺滑):

那具体要做什么呢,我们来简单拆解一下步骤:

  • 绘制坐标系
  • 绘制多条函数曲线
  • 绘制辅助线和坐标点
  • 支持平移、缩放
  • 支持动态绘制曲线
  • 使之高清 下面就来一一攻克它们,如果你在电脑前可以顺便瞅瞅示例代码(传送门),不依赖任何库哦。

第一步:绘制坐标系

既然我们要画函数,那肯定要先搞一个坐标系啦,也就是网格。大家可能都知道怎么画网格,就是横的画几条、竖的画几条。思路是没错,但是具体画起来要考虑的问题还是有很多的。画一个不动的、没有要求的网格很简单,但加了刻度后就不那么容易了。这里我就直接说下会遇到的一些问题吧!

1、如何确定 x 轴和 y 轴的边界值

这个东西很重要,是接下来所有一切操作的开端,因为我们画的是函数,一定要有个 x 轴的边界(当做参数传进来),那 y 轴呢,一般我们的网格都是正方形,所以这里让 y 轴的取值范围跟 x 轴一致即可。如果你不传 x 轴的两个边界值(lefxX、rightX),那我们会给个默认值[-canvas宽度的一半/100, canvas宽度的一半/100],因为一般画布的宽高都是几百,所以这里就简单的除以 100,你要除以 10 也是 ok 的。再次强调一下边界值这个东西很重要,因为接下来的操作都是基于边界值来绘制的。不信你可以尝试一下没有边界值的情况,那可能会无从下手🐶。

2、不是传入多少网格数就是多少网格

因为网格刻度是需要骚微取整的,比如 10、5、1、0.5、0.2、0.1 这样。什么意思呢,举个具体的例子🌰,比如 x 轴的取值范围是 (-5,5),网格的数量是10,那刚好就可以有 10 个网格,这样刻度就会挺整的(-5,-4,-3这样);如果网格数是9,那么我们也会有10个网格,刻度也是(-5,-4,-3这样),而不是(-5,-3.9,-2.8),不知道大家 get 到木有。所以网格大小是要稍微计算一下的,算是个小小的算法,下面是其中的一种解法👇🏻:

/**
 * 计算每一个网格的宽高大小,注意并不是 gridCount 是多少就会有多少网格
 * 因为我们的坐标刻度通常都是 10、5、1、0.5、0.1 这个样子,大部分都是以 2、5、10 的倍数
 * @param len x 或 y 轴代表的长度
 * @param gridCount 网格个数
 */
calcGridSize(len: number, gridCount: number): number[] {
    let gridWidth = 1;
    // 应该保留几位小数,避免浮点数
    let fixedCount = 0;
    // 事实上,要是图方便的话,你也可以直接用 unitX 来当做网格大小,不过记得要取整
    let unitX = len / gridCount;
    // 而这里呢,我们需要找到离 unitX 最近的(稍微偏整数的)值
    // 首先要找到离 unitX 最近的 10 的整数倍,如 0.01、0.1、1、10、100
    while (gridWidth < unitX) {
        gridWidth *= 10
    }
    while (gridWidth / 10 > unitX) {
        gridWidth /= 10
        fixedCount++;
    }
    // 看看能不能再划分一次,如(/5 得到 0.02、0.2、20)、(/2 得到 0.05、5、50)
    if (gridWidth / 5 > unitX) {
        gridWidth /= 5;
        fixedCount++;
    } else if (gridWidth / 2 > unitX) {
        gridWidth /= 2;
        fixedCount++;
    }
    // 因为 x 轴长度和 y 轴的长度是一样的,所以可以这样赋值
    return [gridWidth, gridWidth, fixedCount];
}

3、如何让坐标原点位于画布中心

这个比较抽象,所以我们看图。我们不要这样(注意看四周的网格):

我们要这样(注意看四周的网格,再和上图对比一下):

不知道大家 get 到木有,其实你可以从中间开始往上下、往左右画网格,不过这里我们还是从左到右画就行(做点简单的数学运算),直接看代码应该会清晰点,而且我还配了图😄:

drawGrid() {
    const { width, height, leftX, rightX, leftY, rightY, xLen, yLen, gridCount, ctx2d } = this;
    ctx2d?.save();
    // 注意这里我们是将网格数作为配置项,而不是网格的宽高大小
    const [gridWidth, gridHeight, fixedCount] = this.calcGridSize(xLen, gridCount);
    // 由于计算会产生浮点数偏差,所以要控制下小数点后面的数字个数
    this.fixedCount = fixedCount;
    // 从左到右绘制竖线
    for (let i = Math.floor(leftX / gridWidth); i * gridWidth < rightX; i++) {
        // 绘制像素点 / 整个画布宽度 = 实际 x 值 / 实际表示的 x 轴长度
        const x = (i * gridWidth - leftX) / xLen * width;
        // i = 0 就说明是 y 轴,颜色加深
        const color = i ? '#ddd' : '#000';
        this.drawLine(x, 0, x, height, color)
        this.fillText(String(this.formatNum(i * gridWidth, this.fixedCount)), x, height, this.fontSize, TextAlign.Center);
    }
    // 绘制横线也是和上面一样的方法,就是要注意画布的 y 轴向下,需要用 height 减一下,或者用 scale(1, -1);
    for (let j = Math.floor(leftY / gridHeight); j * gridHeight < rightY; j++) {
        let y = (j * gridWidth - leftY) / yLen * height;
        y = height - y;
        const color = j ? '#ddd' : '#000';
        this.drawLine(0, y, width, y, color);
        this.fillText(String(this.formatNum(j * gridHeight, this.fixedCount)), 0, y, this.fontSize, TextAlign.Middle);
    }
    ctx2d?.restore();
}
// 保留 fixedCount 位小数,整数不补零
formatNum(num: number, fixedCount: number): number {
    return parseFloat(num.toFixed(fixedCount));
}

另外简单说下为什么刻度要画在边上呢,不画在轴上,因为只要你骚微移动或者放大下画布,坐标轴就不在视线范围内了,刻度也就看不见了,所以不如一开始就放边上。

4、刻度总是会有浮点数

刻度和坐标点这种东西总是要做些加减乘除的运算,自然而然就会遇到我们前端知名的浮点数问题,所以这个是需要处理一下的,比如保留几位小数。当然关于浮点数的运算是有库可以用的,大致的思路分为两种:

  • 变成整数在计算(先把数字都乘以10的n次方,最后除以10的n次方)
  • 变成字符串来计算(类似于小学数学的计算方式,比如乘法,与个位、十位、百位...分别相乘再相加)

第二步:画函数曲线

画函数曲线的思路很直白,就是以直代曲,将多个点用线连起来就行了,我们会先通过 y = fn(x) (fn 大概长这样 sin(x)、1 / x 这样)来算出函数的坐标点,然后将函数的坐标点换算成画布上的点,最后绘制出来。下面是一些要小小注意的点:

  • 点越多画出来的曲线就越平滑,这里我们可以通过参数 steps 来控制,默认值为 1。
  • 计算出来的点可以不需要保存下来,因为你一移动、缩放,值就全变了。
  • 记得在绘制每条函数开始前 beginPath 一下,否则会影响到下一条曲线的绘制(比如颜色、线宽)。
  • 对于画布外的点我们是不需要绘制的,直接用 moveTo 即可(但不是用 continue 跳过)。
/** 绘制函数曲线,就是用一段段直线连起来 */
drawFn() {
    const { width, height, leftX, leftY, xLen, yLen, steps, ctx2d } = this;
    if (!ctx2d) return;
    ctx2d.save();
    this.fnList.forEach(fn => {
        ctx2d.strokeStyle = (fn as any).color;
        ctx2d.beginPath();
        for (let i = 0; i < width; i += steps) {
            // 小小的公式推导:像素点 / 画布宽 = x / 实际表示的 x 轴长度
            const x = i / width * xLen + leftX;
            let y = fn(x);
            if (isNaN(y)) continue;
            // 换算到具体绘制点
            y = height - (y - leftY) / yLen * height;
            // 在画布之外是不用绘制的所以用 moveTo 即可
            if (i === 0 || y > height || y < 0) {
                ctx2d.moveTo(i, y);
            } else {
                ctx2d.lineTo(i, y);
            }
        }
        ctx2d.stroke();
    });
    ctx2d.restore();
}

这里想强调一点的就是在绘制前后时刻记得要 save 和 restore,并且这两个方法要配对使用。我们都应该听过 canvas 是基于状态管理的,如果你想要画一条红色的线,我们需要把画笔设置成红色,不使用 save 和 restore 的话,这个红色会影响到接下来的所有操作,所以一般我们要养成下面这样的习惯(尤其是平移、旋转和缩放等操作,不然一脸懵逼不是梦😳):

// 初始状态
ctx2d.save();
// 在这里设置各种状态1
    ctx2d.save();
    // 在这里设置各种状态2
    ctx2d.restore();
// 这里是状态1
ctx2d.restore();
// 这里是初始状态

之所以可以嵌套使用是因为 canvas 使用了栈(只能 push 和 pop 的数组)的方式来管理状态,就像这样 [初始状态的集合、状态1的集合、状态2的集合],save 就是将当前状态往数组末尾追加,restore 就是取出数组最后一项当做当前状态。而集合你可以理解成是一个大对象,里面有各种属性,形如 {strokeStyle, fillStyle, lineWidth} 这样。

第三步:绘制辅助线和交点坐标

这个很简单,首先我们能够知道鼠标相对于画布的坐标,然后换算成曲线的 x 值,代入每个函数中计算出一个 y 值,如果 y 是个有效值,那么就在点 [x, y] 处画个圆即可,记得数字都格式化一下的。

drawSubLine(canvasPos: Point) {
    const ctx2d: CanvasRenderingContext2D | null = this.ctx2d;
    if (!ctx2d) return;
    const { width, height } = this;
    const { x, y } = canvasPos;
    // 先重新绘制网格和函数曲线
    this.draw();
    // 绘制辅助线
    ctx2d.save();
    this.drawLine(x, 0, x, height, '#999',true);
    this.drawLine(0, y, width, y, '#999', true);
    ctx2d.restore();
    // 绘制鼠标点
    const centerRectLen: number = 8;
    this.strokeRect(x - centerRectLen / 2, y - centerRectLen / 2, centerRectLen, centerRectLen);
    const actualPos = this.canvasPosToFnVal(canvasPos);
    // 绘制曲线和辅助线的交点坐标
    this.handleCrosspoint(actualPos.x);
}
handleCrosspoint(x: number) {
    const pointList: Point[] = this.checkCrosspoint(x);
    pointList.forEach(point => {
        const { x, y } = this.fnValToCanvasPos(point);
        this.fillCircle(x, y, 4, 'red');
        this.fillText(`[${this.formatNum(point.x, this.fixedCount + 1)}, ${this.formatNum(point.y, this.fixedCount + 1)}]`, x, y, this.fontSize);
    });
}

绘制辅助线的时候我们总是会先重新绘制网格和曲线,这个是可以优化的。我们可以进行分层处理,因为鼠标移动的时候,就只会改变辅助线和坐标点,所以我们可以将网格和函数曲线放在下层,辅助线和坐标点放在上层,这样每次绘制的时候只要重新绘制上层就可以了。

第四步:平移

首先肯定是要加个事件监听的:

this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this), false);
// 也可以把 mouseup 和 mousemove 这两个监听写在 mousedown 的回调中
document.addEventListener('mouseup', this.handleMouseUp.bind(this), false);
document.addEventListener('mousemove', this.handleMouseMove.bind(this), false);

平移应该是众多操作中最简单的一个,平移的结果就是改变了 x 轴和 y 轴的边界值,所以只需要重新计算出边界值,重新调用绘制函数即可。我们用 startPos 和 endPos 来计算平移的距离,就像下面这样:

// 返回距离画布左上角的点
viewportToCanvasPosition(e: MouseEvent): Point {
    const { clientX, clientY } = e;
    const { top, left } = this.canvas.getBoundingClientRect();
    const x = clientX - top;
    const y = clientY - left;
    return new Point(x, y);
}
// 记录鼠标按下的点
handleMouseDown(e: MouseEvent) {
    const canvasPos: Point = this.viewportToCanvasPosition(e);
    this.state.startPos = canvasPos;
}
// 计算平移距离,更新 leftX 和 rightX 的值,然后重新绘制
handleMouseMove(e: MouseEvent) {
    const canvasPos: Point = this.viewportToCanvasPosition(e);
    if (!this.state.startPos) return;
    document.body.style.cursor = 'move';
    this.state.endPos = canvasPos;
    const { width, height, xLen, yLen, state: { startPos, endPos } } = this;
    // 算出平移距离
    const dx = (endPos.x - startPos.x) / width * xLen;
    const dy = (endPos.y - startPos.y) / height * yLen;
    // 更新边界值
    this.leftX -= dx;
    this.rightX -= dx;
    this.leftY += dy;
    this.rightY += dy;
    this.xLen = this.rightX - this.leftX;
    this.yLen = this.rightY - this.leftY;
    this.draw();
    this.state.startPos = canvasPos;
}
// 还原拖拽前的状态
handleMouseUp(e: MouseEvent) {
    this.state.startPos = null;
    this.state.endPos = null;
    document.body.style.cursor = 'auto';
}

要注意的是如果我们往右平移,上面代码中的 dx 应该是正值,但刻度是减小的,所以计算的时候应该用减号,其它方向同理。

第五步:缩放

首先就是要监听滚轮事件:

this.canvas.addEventListener('mousewheel', this.handleMouseWheel.bind(this), false);

同样的,缩放的核心也是改变边界值,但是这个推导会比平移骚微麻烦一些,自己动手画个图会比较清晰点。另外和平移不一样的是,缩放应该是以当前点为中心进行缩放,并且是一边加一边减。啥意思呢?就是比如你在画布中心放大的时候,leftX 的值应该是增加的,rightX 应该是减少的。还有就是缩放应该要有个限制,无限大或者无限小都没啥太大意义。下面是代码和图的示例:

handleMouseWheel(e: Event) {
    e.preventDefault();
    const event: WheelEvent = e as WheelEvent;
    const canvasPos: Point = this.viewportToCanvasPosition(event);
    const { deltaY } = event;
    const { leftX, rightX, leftY, rightY } = this;
    const scale: number = deltaY > 0 ? 1.1 : 0.9;
    if (this.isInvalidVal(scale)) return;
    const { x, y } = this.canvasPosToFnVal(canvasPos);
    // 注意缩放和平移不一样,轴的左右两边一边是加一边是减
    this.leftX = x - (x - leftX) * scale;
    this.rightX = x + (rightX - x) * scale;
    this.leftY = y - (y - leftY) * scale;
    this.rightY = y + (rightY - y) * scale;
    this.xLen = this.rightX - this.leftX;
    this.yLen = this.rightY - this.leftY;
    this.draw();
}
/** 缩放过大过小都没啥意义,所以设置下边界值 */
isInvalidVal(ratio: number): boolean {
    const { xLen, yLen, MIN_DELTA, MAX_DELTA } = this;
    if (ratio > 1 && (xLen > MAX_DELTA || yLen > MAX_DELTA)) return true;
    if (ratio < 1 && (xLen < MIN_DELTA || yLen < MIN_DELTA)) return true;
    // 上面的判断为什么不直接 (xLen > MAX_DELTA || yLen > MAX_DELTA || xLen < MIN_DELTA || yLen < MIN_DELTA)这样判断呢?
    // 因为如果这样判断你会发现缩放到最大和最小的时候,再继续操作都是无效的。
    return false;
}
canvasPosToFnVal(canvasPos: Point): Point {
    const { width, height, leftX, leftY, xLen, yLen } = this;
    const x = leftX + canvasPos.x / width * xLen;
    const y = leftY + canvasPos.y / height * yLen;
    return new Point(x, y);
}

第六步:动态绘制曲线

既然你已经会画函数曲线了,那动态绘制也不在话下。就是用 requestAnimationFrame、setTimeout 或者 setInterval 一次画一段,绘制的快慢可以通过调整时间参数或者每次画多段来调整,然后有以下几个注意点:

  • 动画的时候整个画布应该是不可操作的状态。
  • 我们的曲线是一条绘制完再绘制另一条的,而不是从左到右同时绘制每条曲线,当然怎么做都可以。
  • 有的曲线左边开始部分没有图像,就会出现等待一段时间后才开始绘制的情况,所以我们应该跳过不用绘制的点。

第七步:模糊到高清

其实写到最后你会发现曲线好像有点模糊,事实上整个画布都会有点模糊,这在 canvas 中是个很常见的问题,以上就是JS前端使用canvas动态绘制函数曲线示例详解的详细内容,更多关于JS canvas绘制函数曲线的资料请关注阿兔在线工具其它相关文章!

点赞(0)

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部