線性代數在前端中的應用(二):實現滑鼠拖拽旋轉元素、Canvas圖形

而井發表於2022-03-12

簡介

看到文章標題,很多同學可能會疑惑,實現元素的旋轉,只需要求得旋轉角度,然後用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");
    });
});

相關文章