乐趣区

关于前端:线性代数在前端中的应用二实现鼠标拖拽旋转元素Canvas图形

简介

看到文章题目,很多同学可能会纳闷,实现元素的旋转,只须要求得旋转角度,而后用 CSS 中的 transform:rotate(${旋转的角度}deg) 就能够实现旋转的需要,为什么要用到 线性代数 的常识?

我感觉用 线性代数 的常识实现元素拖拽旋转的理由如下:

  • 矩阵中能够同时蕴含旋转、缩放、平移等信息,不须要进行冗余的计算和属性更新;
  • 更加通用。线性代数 的常识作为一种数学知识,是形象的、通用的,很多 GUI 编程技术都提供了 线性代数矩阵 实现元素旋转、缩放、平移等成果,例如 CSStransform属性的 matrix()Canvas 中提供的 setTransform()API,安卓 Canvas 类提供的 setMatrix() 办法。学会 线性代数矩阵旋转 ,就能够在各个GUI 编程技术中通吃此类需要。

拖拽旋转的原理剖析

拖拽旋转实质上是绕着原点旋转,这个原点就是物体的核心。让咱们用一个矩形来形象表白这个旋转过程,以矩形核心为原点 \(O\),建设 \(2D\)坐标系,取一点为旋转起始点 \(A\),取一点为旋转完结点 \(A’\),将 \(A\)、\(A’\)与 \(O\)连接起来可得向量 \(\overrightarrow{OA}\)、向量 \(\overrightarrow{OA’}\),向量 \(\overrightarrow{OA}\)和向量 \(\overrightarrow{OA’}\)之间的夹角 \(\theta\),可得如下图:

在 JavaScript 中 Math.atan2()API 能够返回从 \(原点(0,0)\) 到 \((x,y)点 \)的线段与 \(x 轴 \)正方向之间的立体角度(弧度值),所以可得求取两个向量之间的夹角弧度的代码如下:

/**
 * 计算向量夹角,单位是弧度
 * @param {Array.<2>} av 
 * @param {Array.<2>} bv 
 * @returns {number}
 */
    function computedIncludedAngle(av, bv) {return Math.atan2(av[1], av[0]) - Math.atan2(bv[1], bv[0]);
    }

旋转矩阵

在前文线性代数在前端中的利用(一):实现鼠标滚轮缩放元素、Canvas 图片和拖拽中,咱们晓得了缩放元素能够利用 缩放矩阵 ,那么旋转元素也能够利用 旋转矩阵 ,那么怎么推导出 旋转矩阵 就成了要害。因为咱们目前只关怀立体维度上的旋转,所以只须要求得 \(2D\)维度中的 旋转矩阵 即可。

假如在 \(2D\)坐标轴中有和 \(X 轴 \)、\(Y 轴 \)别离平行的基向量 \(p\)和基向量 \(q\),它们之间的夹角为 \(90^{\circ}\),将基向量 \(p\)和基向量 \(q\)同时旋转 \(\theta 度 \),能够失去基向量 \(p’\)和基向量 \(q’\),依据 \(三角函数 \)即能够推导出 \(p\)、\(p’\)的值。

利用基向量结构矩阵,\(2D\)旋转矩阵就如下:

$$
R(\theta)=\left[\begin{matrix} p^{‘} \\ q^{‘} \\ \end{matrix} \right]=\left[\begin{matrix} cos\theta & sin\theta \\ -sin\theta & cos\theta \end{matrix} \right]
$$

转化为 \(4\times4 齐次矩阵 \)则为:

$$
R(\theta)=\left[\begin{matrix} p^{‘} \\ q^{‘} \\ r^{‘}\\ w^{‘} \\ \end{matrix} \right]=\left[\begin{matrix} cos\theta & sin\theta & 0 & 0 \\ -sin\theta & cos\theta & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{matrix} \right]
$$

CSS 中实现矩阵变动的 matrix() 函数

CSS 函数 matrix() 指定了一个由指定的 6 个值组成的 2D 变换矩阵。
matrix(a, b, c, d, tx, ty) 是 matrix3d(a, b, 0, 0, c, d, 0, 0, 0, 0, 1, 0, tx, ty, 0, 1) 的简写。

这些值示意以下函数:

matrix(scaleX(), skewY(), skewX(), scaleY(), translateX(), translateY())

例如咱们要一个 div 元素放大两倍,程度向右平移 100px,垂直向下平移 200px,能够把 CSS 写成:

div {transform:matrix(2, 0, 0, 2, 100, 200);
}

因为咱们采纳的是 \(4\times4 齐次矩阵 \)进行矩阵变换计算,所以采纳 \(RP^{3}下的齐次坐标 \)。值得注意的是,对于 \(齐次坐标 \)咱们还能够写成上面这种模式,本文咱们将采纳这种模式:

$$
\left[\begin{matrix} a & c & 0 & 0 \\ b & d & 0 & 0 \\ 0 & 0 & 1 & 0 \\ tx & ty & 0 & 1 \end{matrix} \right]
$$

矩阵计算库 gl-matrix

gl-matrix 是一个用 JavaScript 语言编写的开源矩阵计算库。咱们能够利用这个库提供的矩阵之间的运算性能,来简化、减速咱们的开发。为了防止升高复杂度,后文采纳原生 ES6 的语法,采纳 <script> 标签间接援用 JS 库,不引入任何前端编译工具链。

鼠标拖拽旋转 Div 元素

旋转成果

代码实现

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title> 矩阵旋转 Div 元素 </title>
    <link rel="stylesheet" href="./index.css">
</head>
<body>
    <div class="shape_controls">
        <div class="shape_anchor"></div>
        <div class="shape_rotater"></div>
    </div>
    <script src="./gl-matrix-min.js"></script>
    <script src="./index.js"></script>
</body>
</html>

index.css

*,
*::before,
*::after {box-sizing: border-box;}

body {
    position: relative;
    margin: 0;
    padding: 0;
    min-height: 100vh;
}

.shape_controls {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 200px;
    height: 200px;
    border: 1px solid rgb(0, 0, 0);
    z-index: 1;
}

.shape_controls .shape_anchor {
    position: absolute;
    left: 50%;
    top: 0%;
    transform: translate(-50%, -50%);
    width: 8px;
    height: 8px;
    border: 1px solid rgb(6, 123, 239);
    border-radius: 50%;
    background-color: rgb(255, 255, 255);
    z-index: 2;
}

.shape_controls .shape_rotater {
    position: absolute;
    left: 50%;
    top: -30px;
    transform: translate(-50%, 0);
    width: 8px;
    height: 8px;
    border: 1px solid rgb(6, 123, 239);
    border-radius: 50%;
    background-color: rgb(255, 255, 255);
    z-index: 2;
}

.shape_controls .shape_rotater:hover {cursor: url(./rotate.gif) 16 16, auto;
}

.shape_controls .shape_rotater::after {
    position: absolute;
    content: "";
    left: 50%;
    top: calc(100% + 1px);
    transform: translate(-50%, 0);
    height: 18px;
    width: 1px;
    background-color: rgb(6, 123, 239);
}

rotate.gif

index.js

document.addEventListener("DOMContentLoaded", () => {const $sct = document.querySelector(".shape_controls");
    const $srt = document.querySelector(".shape_controls .shape_rotater");
    const {left, top, width, height} = $sct.getBoundingClientRect();
    // 原点坐标
    const origin = [left + width / 2 , top + height / 2];
    // 是否旋转中
    let rotating = false;
    // 旋转矩阵
    let prevRotateMatrix = getElementTranformMatrix($sct);
    let aVector = null;
    let bVector = null;

    /**
     * 获取元素的变换矩阵
     * @param {HTMLElement} el 元素对象
     * @returns {Array.<16>} 
     */
    function getElementTranformMatrix(el) {const matrix = getComputedStyle(el)
                        .transform
                        .replace("matrix(", "")
                        .replace(")", "")
                        .split(",")
                        .map(item => parseFloat(item.trim()));
        return new Float32Array([matrix[0], matrix[2], 0, 0,
            matrix[1], matrix[3], 0, 0,
            0, 0, 1, 0,
            matrix[4], matrix[5], 0, 1
        ]);
    }

    /**
     * 给元素设置变换矩阵
     * @param {HTMLElement} el 元素对象
     * @param {Array.<16>} hcm 齐次坐标 4x4 矩阵 
     */
    function setElementTranformMatrix(el, hcm) {el.setAttribute("style", `transform: matrix(${hcm[0]} ,${hcm[4]}, ${hcm[1]}, ${hcm[5]}, ${hcm[12]}, ${hcm[13]});`);
    }

    /**
     * 计算向量夹角,单位是弧度
     * @param {Array.<2>} av 
     * @param {Array.<2>} bv 
     * @returns {number}
     */
    function computedIncludedAngle(av, bv) {return Math.atan2(av[1], av[0]) - Math.atan2(bv[1], bv[0]);
    }

    // 监听元素的点击事件,如果点击了旋转圆圈,开始设置起始旋转向量
    $srt.addEventListener("mousedown", (e) => {const {clientX, clientY} = e;
        rotating = true;
        aVector = [clientX - origin[0], clientY - origin[1]];
    });

    // 监听页面鼠标挪动事件,如果处于旋转状态中,就计算出旋转矩阵,从新渲染
    document.addEventListener("mousemove", (e) => {
        // 如果不处于旋转状态,间接返回,防止不必要的无意义渲染
        if (!rotating) {return;}
        // 计算出以后坐标点与原点之间的向量
        const {clientX, clientY} = e;
        bVector = [clientX - origin[0], clientY - origin[1]];
        // 依据 2 个向量计算出旋转的弧度
        const angle  = computedIncludedAngle(aVector, bVector);

        const o = new Float32Array([
            0, 0, 0, 0,
            0, 0, 0, 0,
            0, 0, 0, 0,
            0, 0, 0, 0
        ]);
        // 旋转矩阵
        const rotateMatrix = new Float32Array([Math.cos(angle), Math.sin(angle), 0, 0,
            -Math.sin(angle), Math.cos(angle), 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        ]);
        // 把以后渲染矩阵依据旋转矩阵,进行矩阵变换,失去新矩阵
        prevRotateMatrix = glMatrix.mat4.multiply(o, prevRotateMatrix, rotateMatrix); 
        // 给元素设置变换矩阵,实现旋转
        setElementTranformMatrix($sct, prevRotateMatrix);
        aVector = bVector;
    });

    // 鼠标弹起后,移除旋转状态
    document.addEventListener("mouseup", () => {rotating = false;})    
});

鼠标拖拽旋转 Canvas 图形

旋转成果

代码实现

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title> 矩阵旋转 Canvas 图形 </title>
    <link rel="stylesheet" href="./index.css">
</head>
<body>
    <canvas id="app"></canvas>
    <script src="./gl-matrix-min.js"></script>
    <script src="./index.js"></script>
</body>
</html>

index.css

*,
*::before,
*::after {box-sizing: border-box;}

body {
    margin: 0;
    padding: 0;
    overflow: hidden;
}

canvas {display: block;}

.rotating,
.rotating div {cursor: url(./rotate.gif) 16 16, auto !important;
}

index.js

document.addEventListener("DOMContentLoaded", () => {
    const pageWidth = document.documentElement.clientWidth;
    const pageHeight = document.documentElement.clientHeight;
    const $app = document.querySelector("#app");
    const ctx = $app.getContext("2d");
    $app.width = pageWidth;
    $app.height = pageHeight;
    const width = 200;
    const height = 200;
    const cx = pageWidth / 2;
    const cy = pageHeight / 2;
    const x = cx - width / 2;
    const y = cy - height / 2;
    // 原点坐标
    const origin = [x + width / 2 , y + height / 2];
    // 是否旋转中
    let rotating = false;
    let aVector = null;
    let bVector = null;
    // 以后矩阵
    let currentMatrix = new Float32Array([
        1, 0, 0, 0,
        0, 1, 0, 0,
        0, 0, 1, 0,
        origin[0], origin[1], 0, 1
    ]);

    /**
     * 计算向量夹角,单位是弧度
     * @param {Array.<2>} av 
     * @param {Array.<2>} bv 
     * @returns {number}
     */
    function computedIncludedAngle(av, bv) {return Math.atan2(av[1], av[0]) - Math.atan2(bv[1], bv[0]);
    }

    /**
     * 渲染视图
     * @param {MouseEvent} e 鼠标对象 
     */
    function render(e) {
        // 清空画布内容
        ctx.clearRect(0, 0, ctx.canvas.width,  ctx.canvas.height);
        ctx.save();

        // 设置线段厚度,避免在高分屏下线段发虚的问题
        ctx.lineWidth = window.devicePixelRatio;

        // 设置变换矩阵
        ctx.setTransform(currentMatrix[0], currentMatrix[4], currentMatrix[1], currentMatrix[5], currentMatrix[12], currentMatrix[13]);
        
        // 绘制矩形
        ctx.strokeRect(-100, -100, 200, 200);

        // 设置圆圈的边框色彩和填充色
        ctx.fillStyle = "rgb(255, 255, 255)";
        ctx.strokeStyle = "rgb(6, 123, 239)";
    
        // 绘制矩形上边框两头的蓝色圆圈
        ctx.beginPath();
        ctx.arc(0, -100, 4, 0 , 2 * Math.PI);
        ctx.stroke();
        ctx.fill();

        // 绘制能够拖拽旋转的蓝色圆圈
        ctx.beginPath();
        ctx.arc(0, -130, 4, 0 , 2 * Math.PI);
        ctx.stroke();
        ctx.fill();

        // 判断是否拖拽旋转的蓝色圆圈
        const {pageX, pageY} = e ? e : {pageX: -99999, pageY: -9999};
        if (ctx.isPointInPath(pageX, pageY)) {rotating = true;}
        // 绘制链接两个圆圈的直线
        ctx.beginPath();
        ctx.fillStyle = "transparent";
        ctx.strokeStyle = "#000000";
        ctx.moveTo(0, -125);
        ctx.lineTo(0, -105);
        ctx.stroke();

        ctx.restore();}

    // 首次渲染
    render();

    // 监听画布的点击事件,如果点击了旋转圆圈,开始设置起始旋转向量
    $app.addEventListener("mousedown", (e) => {
        // 在渲染的过程中会判断是否点击了旋转圆圈,如果是,那么 rotating 会被设置为 true
        render(e);
        if (!rotating) {return;}
        const {offsetX, offsetY} = e;
        aVector = [offsetX - origin[0], offsetY - origin[1]];
    });

    // 监听页面鼠标挪动事件,如果处于旋转状态中,就计算出旋转矩阵,从新渲染
    document.addEventListener("mousemove", (e) => {
        // 如果不处于旋转状态,间接返回,防止不必要的无意义渲染
        if (!rotating) {return;}
        // 给画布增加旋转款式
        $app.classList.add("rotating");

        // 计算出以后坐标点与原点之间的向量
        const {offsetX, offsetY} = e;
        bVector = [offsetX - origin[0], offsetY - origin[1]];
        // 依据 2 个向量计算出旋转的弧度
        const angle = computedIncludedAngle(aVector, bVector);

        // 旋转矩阵
        const rotateMatrix = new Float32Array([Math.cos(angle), Math.sin(angle), 0, 0,
            -Math.sin(angle), Math.cos(angle), 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        ]);
        // 把以后渲染矩阵依据旋转矩阵,进行矩阵变换,失去画布的新渲染矩阵
        currentMatrix = glMatrix.mat4.multiply(glMatrix.mat4.create(),
            currentMatrix,
            rotateMatrix,
        );
        render(e);
        aVector = bVector;
    });

    // 鼠标弹起后,移除旋转状态
    document.addEventListener("mouseup", () => {    
        rotating = false;
        $app.classList.remove("rotating");
    });
});
退出移动版