簡介
看到文章標題,很多同學可能會疑惑,實現元素的旋轉,只需要求得旋轉角度,然後用CSS
中的transform:rotate(${旋轉的角度}deg)
就可以實現旋轉的需求,為什麼要用到線性代數
的知識?
我覺得用線性代數
的知識實現元素拖拽旋轉的理由如下:
- 矩陣中可以同時包含旋轉、縮放、平移等資訊,不需要進行冗餘的計算和屬性更新;
- 更加通用。
線性代數
的知識作為一種數學知識,是抽象的、通用的,很多GUI
程式設計技術都提供了線性代數矩陣
實現元素旋轉、縮放、平移等效果,例如CSS
中transform
屬性的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");
});
});