簡介
在前端開發中,有些時候會遇到根據滑鼠當前位置為原點,滾動滾輪實現圖片、canvas、DOM元素縮放的需求。有些同學可能覺得有點難,但其實藉助線性代數中的矩陣運算,可以非常容易地實現這一功能,更重要的是,數學作為一門學科,具有通用性,與具體的程式語言和環境無關,掌握好原理便可以實現通用性。
縮放的本質
縮放的本質是矩陣變換。
當我們想縮放一個Div元素的時候,一般來說我們可以將其看成是對一個矩形的縮放。為了便於理解,我們這裡以一個最簡單的矩形的縮放為例子。如下圖我們假定有一個邊長都為4的矩形,我們以它的中心為原點,建立二維XY座標軸,可以得到如下圖:
當我們將矩形放大2倍,會得到一個邊長都為8的矩形,繼續以中心為原點,建立二維XY座標軸,可以得到下圖:
如果我們對這兩張圖的圖形座標點進行數學抽象,便可以得到以下兩個矩陣:
矩陣A:
$$ \left[ \begin{matrix} -2 & 2 \\ 2 & 2 \\ 2 & -2 \\ -2 & -2 \\ \end{matrix} \right] $$
矩陣B:
$$ \left[ \begin{matrix} -4 & 4 \\ 4 & 4 \\ 4 & -4 \\ -4 & -4 \\ \end{matrix} \right] $$
也就是說矩形放大2倍這件事情,其實不過是矩陣A變換成矩陣B,這樣我們就巧妙地將矩形縮放的問題,轉化為矩陣之間的轉換問題,可以藉助矩陣數學公式進行抽象計算,接下來我們來了解下矩陣變換的基礎:矩陣乘法。
矩陣乘法
設A為的矩陣,B為的矩陣,那麼稱的矩陣C為矩陣A與B的乘積,記作,其中矩陣C中的第行第列元素可以表示為:
如下所示:
還有一個原則需要特別注意的是:僅當矩陣A
的列數(column)
等於矩陣B
的行數(row)
時,A與B才可以相乘,否則不能矩陣相乘,這一點要切記!因為後面因為這個原則和方便計算,我們會把4x2
矩陣轉為4x4
矩陣。
為了便於理解,這裡擷取了《3D數學基礎:圖形與遊戲開發》
這本書中關於3x3矩陣乘法
的介紹,輔助大家理解和回憶矩陣乘法的具體細節。
矩陣變換
當討論變換時,在數學上一般用到函式(也稱對映)
,即接受輸入,產生輸出。我們可以把a
到b
的F
函式/對映記為F(a)=b
。要利用數學工具來解決矩陣之間變換(縮放是變換的一種,其他還有平移、旋轉、切變等),最簡單的方式也就是找到矩陣表達的對映,以及其運算規則。
在小學時,我們都學過數學的四則運算,例如現在存在一個數a
,如果我們想要把a
變成原來2倍,我們會使用:
$$ a' = a * 2 $$
假如我們要縮放矩陣,那麼我們也需要找到類似的乘法規則,即一個矩陣和什麼樣的矩陣相乘可以得到它的倍數。還記得我們從幼兒園開始學習的數學知識麼?除了0這個特殊的數字外,我們認識這個數字的世界是從1
開始,由1
的相加、減得到其他數字,例如我們上面需要的2
,可以由$$ 1 + 1 $$來獲得,那麼矩陣裡的那個1
是什麼,便成為一件重要的事情。
矩陣裡的那個1
——單位矩陣
在矩陣的乘法中,有一種矩陣起著特殊的作用,如同數的乘法中的1,這種矩陣被稱為單位矩陣。它是個方陣,從左上角到右下角的對角線(稱為主對角線)上的元素均為1。除此以外全都為0。
2x2
的單位矩陣$$ \left[ \begin{matrix} 1 & 0 \\ 0 & 1 \end{matrix} \right]$$,3x3
的單位矩陣$$ \left[ \begin{matrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{matrix} \right]$$,4x4
的單位矩陣$$ \left[ \begin{matrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1\\ \end{matrix} \right]$$
根據單位矩陣的特點,任何矩陣與單位矩陣相乘都等於本身。
那既然知道了什麼是"1"
,那"2"
是什麼呢?其實不難猜出,例如2x2
矩陣的"2"
即為$$ \left[ \begin{matrix} 2 & 0 \\ 0 & 2 \end{matrix} \right]$$,也就是如果存在2x2
矩陣$$ A = \left[ \begin{matrix} 1 & 0 \\ 0 & 1 \end{matrix} \right]$$,那麼如果$$ B = A * \left[ \begin{matrix} 2 & 0 \\ 0 & 2 \end{matrix} \right] $$,根據上文提到的矩陣乘法
的計算規則,我們可以得到$$ B = \left[ \begin{matrix} 2 & 0 \\ 0 & 2 \end{matrix} \right] $$,那麼我們可以認為B
矩陣是A
矩陣放大後的2倍。
沿座標軸的縮放
上文提到將矩陣放大2倍的說法,是為了方便理解,實際上更準確地來講,是沿座標軸進行放大,因為除了沿座標軸縮放
外,還可以沿任意方向縮放
,例如朝著座標軸第一象限45度方向進行縮放。由於本文滑鼠滾輪縮放
暫且不涉及到沿任意方向縮放
,所以這個以後有空再寫文章來講解。
沿座標軸的2D縮放矩陣
如果存在一個矩陣為$$ M= \left[\begin{matrix} p & 0 \\ 0 & q \end{matrix}\right]$$,我們把它看成是2D座標軸上分別平行與X軸的向量p
、平行與Y軸的向量q
這兩個基向量
。假定有2個縮放因子:\( k_{x} \)和\( k_{y} \),那麼有:
$$ p^{'}=k_{x}p=k_{x}\left[\begin{matrix} 1 & 0 \end{matrix}\right]=\left[\begin{matrix} k_{x} & 0 \end{matrix}\right] $$
$$ q^{'}=k_{y}p=k_{y}\left[\begin{matrix} 0 & 1 \end{matrix}\right]=\left[\begin{matrix} k_{y} & 0 \end{matrix}\right] $$
利用基向量構造矩陣,沿座標軸的2D縮放矩陣就如下:
$$ S(k_{x},k_{y})=\left[ \begin{matrix} p^{'} \\ q^{'} \\ \end{matrix} \right]=\left[ \begin{matrix} k_{x} & 0 \\ 0 & k_{y} \end{matrix} \right] $$
例如一個代表2D平面的矩陣\(M\)要在\(X\)軸放大2倍,\(Y\)軸縮小3倍,那麼就可以這樣做去獲得轉換後的矩陣\(M^{'}\):
$$ M^{'}=M*\left[ \begin{matrix} 2 & 0 \\ 0 & \frac{1}{3} \end{matrix} \right] $$
沿座標軸的3D縮放矩陣
對於3D,增加第三個縮放因子\(k_{z}\),沿座標軸的3D縮放矩陣就如下:
$$ S(k_{x},k_{y},k_{z})=\left[ \begin{matrix} k_{x} & 0 & 0 \\ 0 & k_{y} & 0 \\ 0 & 0 & k_{z} \end{matrix} \right] $$
沿座標軸的4D縮放矩陣
對於4D,增加第四個縮放因子\(k_{W}\),沿座標軸的4D縮放矩陣就如下:
$$ S(k_{x},k_{y},k_{z},k_{w})=\left[ \begin{matrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ 0 & 0 & 0 & k_{w} \end{matrix} \right] $$
如何用3D矩陣表示2D矩陣?
3D矩陣和2D矩陣相比,矩陣多了關於\(Z\)軸的表達,由於二維平面可以看成是在三維座標系中"被拍平的物體"
,我們需要給其一個\(Z\)軸值,但不能為0
,此時\(Z\)軸的值為1
。
例如上文提及的2D矩陣A:$$\left[ \begin{matrix} -2 & 2 \\ 2 & 2 \\ 2 & -2 \\ -2 & -2 \\ \end{matrix} \right]$$,轉化為3D矩陣即為:$$\left[ \begin{matrix} -2 & 2 & 1 \\ 2 & 2 & 1 \\ 2 & -2 & 1 \\ -2 & -2 & 1 \\ \end{matrix} \right]$$
如何用4D矩陣表示2D矩陣?
4D矩陣和2D矩陣相比,矩陣多了關於\(Z\)軸和\(W\)軸的表達。
例如上文提及的2D矩陣A:$$\left[ \begin{matrix} -2 & 2 \\ 2 & 2 \\ 2 & -2 \\ -2 & -2 \\ \end{matrix} \right]$$,轉化為4D矩陣即為:$$\left[ \begin{matrix} -2 & 2 & 1 & 1\\ 2 & 2 & 1 &1 \\ 2 & -2 & 1 & 1 \\ -2 & -2 & 1 & 1 \\ \end{matrix} \right]$$
矩陣計算庫gl-matrix
gl-matrix是一個用JavaScript
語言編寫的開源矩陣計算庫。我們可以利用這個庫提供的矩陣之間的運算功能,來簡化、加速我們的開發。為了避免降低複雜度,後文採用原生ES6
的語法,採用<script>
標籤直接引用JS
庫,不引入任何前端編譯工具鏈。
以滑鼠當前位置為原點縮放元素
前文我們已經將元素的縮放簡化成矩形的縮放,接下來繼續進行抽象,將矩形的縮放簡化為座標點在座標軸中的縮放,以點窺面。
假設在\(XY座標軸\)中有兩個座標點\(\left( -3,0 \right)\)和\(\left( 3,0 \right)\),它們之間的距離為6
,如下圖:
將兩個座標點\(\left( -3,0 \right)\)和\(\left( 3,0 \right)\)以原點為中心、沿著\(X軸\)放大2倍延伸,可以得到新座標點\(\left( -6,0 \right)\)和\(\left( 6,0 \right)\),它們之間的距離為12
,如下圖:
如果要保持放大後,維持兩個座標點的距離為12
個單位,而\(X軸\)正方向那個座標點的位置不變,那麼我們需要在放大後,將兩個座標點沿著\(X軸\)向左平移3
個單位,即-3
,如下圖:
觀察可得:
$$ -3=3-3*2 = 3*(1-2) \\ 即: 縮放後在X/Y軸上偏移量=X/Y座標值*(1-縮放倍數) $$
其實上述的過程就是以當前滑鼠點為原點縮放圖形
的過程抽象,即:先縮放圖形,然後把原來的縮放點平移回先前的位置。
4x4平移矩陣
由於3x3變換矩陣
表示的是線性變換,不包含平移
,但是在4D中,仍然可以用4x4矩陣
的矩陣乘法來表達平移:
$$ \left[\begin{matrix}x &y &z &1 \end{matrix}\right]\left[\begin{matrix}1 &0 &0 &0\\ 0&1&0&0\\0&0&1&0\\\Delta x &\Delta y &\Delta z&1 \end{matrix}\right]=\left[\begin{matrix}x+\Delta x &y+\Delta y &z+\Delta z &1 \end{matrix}\right] $$
矩陣計算表達先縮放後平移
假定現有矩陣\(v\),它先縮放再平移,縮放矩陣為$$R=\left[ \begin{matrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ 0 & 0 & 0 & k_{w} \end{matrix} \right]$$,平移矩陣為$$T=\left[\begin{matrix}1 &0 &0 &0\\ 0&1&0&0\\0&0&1&0\\\Delta x &\Delta y &\Delta z&1 \end{matrix}\right]$$,那麼:
$$v^{'}=v*R*T$$
矩陣實現Div元素以滑鼠為原點進行縮放
假定現在頁面有一個ID
為app
的div
元素,位於頁面中間位置,程式碼如下:
<!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>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
position: relative;
background-color: #eee;
min-height: 1000px;
margin: 0;
padding: 0;
}
#app {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 200px;
height: 200px;
border: 1px dashed black;
}
</style>
</head>
<body>
<div id="app"></div>
<script src="./gl-matrix-min.js"></script>
<script src="./index.js"></script>
</body>
</html>
佈局效果如下:
首先我們需要獲得關於Div元素位置資訊和寬高資訊,用它們來組成矩陣,這個可以藉助# Element.getBoundingClientRect()
這個api。
然後監聽div#app
滑鼠滾動事件,滾動時,根據事件物件的deltaY
的值來判斷是放大還是縮小,這裡為了和Windows系統原生縮放方向保持一致,選擇滾輪向下滾動時縮小,滾輪向上滾動時放大,即deltaY
的值小於0
時放大,小於0
時縮小。
矩陣變換乘法,這裡由於我們是採用4x4矩陣
,所以可以利用glMatrix.mat4.multiply
這個api,故有程式碼如下:
document.addEventListener("DOMContentLoaded", () => {
const $app = document.querySelector(`#app`);
$app.addEventListener("wheel", (e) => {
const {clientX, clientY, deltaY } = e;
let scale = 1 + (deltaY < 0 ? 0.1 : -0.1);
scale = Math.max(scale > 0 ? scale : 1, 0.1);
const {top, right, bottom, left} = $app.getBoundingClientRect();
const o = new Float32Array([
left, top, 1, 1,
right, top, 1, 1,
right, bottom, 1, 1,
left, bottom, 1, 1
]);
const x = clientX * (1 - scale);
const y = clientY * (1 - scale);
const t = new Float32Array([
scale, 0, 0, 0,
0, scale, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
const m = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
x, y, 0, 1
]);
// 在XY軸上進行縮放
let res1 = glMatrix.mat4.multiply(new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
]), t, o);
// 在XY軸上進行平移
const res2 = glMatrix.mat4.multiply(new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0
]), m, res1);
$app.setAttribute("style", `left: ${res2[0]}px; top: ${res2[1]}px;width: ${res2[4] - res2[0]}px;height: ${res2[9] - res2[1]}px;transform: none;`);
});
});
效果如下圖:
矩陣實現Div元素拖拽
用矩陣實現Div元素拖拽和我們平時實現拖拽的程式碼差不多,只是將絕對定位資訊資料組成平移矩陣
,具體程式碼如下:
document.addEventListener("DOMContentLoaded", () => {
const $app = document.querySelector(`#app`);
const width = $app.offsetWidth;
const height = $app.offsetHeight;
let isDrag = false;
let x; // 滑鼠拖拽時滑鼠的橫座標值
let y; // 滑鼠拖拽時滑鼠的縱座標值
let left; // 元素距離頁面左上角頂點的橫座標偏移值
let top; // 元素距離頁面左上角頂點的縱座標偏移值
$app.addEventListener("mousedown", (e) => {
const bcr = $app.getBoundingClientRect();
isDrag = true;
x = e.clientX;
y = e.clientY;
left = bcr.left + window.scrollX;
top = bcr.top + window.scrollY;
});
document.addEventListener("mousemove", (e) => {
if (!isDrag) {
return;
}
const {clientX, clientY} = e;
const movementX = clientX - (x - left); // 計算出X軸的偏移量
const movementY = clientY - (y - top); // 計算出Y軸的偏移量
// 平移矩陣
const t = new Float32Array([
movementX, movementY
]);
// 計算出相對於頁面左上角的絕對定位的矩陣
const res = glMatrix.mat2.add(new Float32Array([0, 0]), t, new Float32Array([0, 0]));
$app.setAttribute("style", `left: ${res[0]}px;top:${res[1]}px;width:${width}px;height:${height}px;transform: none;`);
})
document.addEventListener("mouseup", () => {
isDrag = false;
});
});
矩陣同時實現Div元素拖拽和縮放
由於矩陣乘法符合結合律,假定現有矩陣\(v\),它先縮放再平移,縮放矩陣為$$R=\left[ \begin{matrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ 0 & 0 & 0 & k_{w} \end{matrix} \right]$$,平移矩陣為$$T=\left[\begin{matrix}1 &0 &0 &0\\ 0&1&0&0\\0&0&1&0\\\Delta x &\Delta y &\Delta z&1 \end{matrix}\right]$$,故而有:
$$v^{'}=v*R*T=v*(\left[ \begin{matrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ 0 & 0 & 0 & k_{w} \end{matrix} \right]\left[\begin{matrix}1 &0 &0 &0\\ 0&1&0&0\\0&0&1&0\\\Delta x &\Delta y &\Delta z&1 \end{matrix}\right])=v*\left[ \begin{matrix} k_{x} & 0 & 0 & 0 \\ 0 & k_{y} & 0 & 0 \\ 0 & 0 & k_{z} & 0 \\ \Delta x &\Delta y &\Delta z & k_{w} \end{matrix} \right]$$
下面是同時實現Div元素拖拽和縮放的程式碼:
document.addEventListener("DOMContentLoaded", () => {
const $app = document.querySelector(`#app`);
let isDrag = false;
let x; // 滑鼠拖拽時滑鼠的橫座標值
let y; // 滑鼠拖拽時滑鼠的縱座標值
let left; // 元素距離頁面左上角頂點的橫座標偏移值
let top; // 元素距離頁面左上角頂點的縱座標偏移值
function reDraw(el, t, move=false) {
const bcr = el.getBoundingClientRect();
const {width, height} = bcr;
const o = new Float32Array([
bcr.left, bcr.top, 1, 1,
bcr.right, bcr.top, 1, 1,
bcr.right, bcr.bottom, 1, 1,
bcr.left, bcr.bottom, 1, 1,
]);
const out = new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
]);
const res = glMatrix.mat4.multiply(out, t, o);
const left = parseInt(res[0]);
const top = parseInt(res[1]);
// 如果是移動,那麼不需要調整寬高
const w = move ? width : res[4] - left;
const h = move ? height : res[9] - top;
el.setAttribute("style", `left: ${left}px;top:${top}px;width:${w}px;height:${h}px;transform: none;`);
}
$app.addEventListener("mousedown", (e) => {
const bcr = $app.getBoundingClientRect();
isDrag = true;
x = e.clientX;
y = e.clientY;
left = bcr.left + window.scrollX;
top = bcr.top + window.scrollY;
});
document.addEventListener("mousemove", (e) => {
if (!isDrag) {
return;
}
const {clientX, clientY} = e;
const movementX = clientX - (x - left); // 計算出X軸的偏移量
const movementY = clientY - (y - top); // 計算出Y軸的偏移量
// 4x4平移矩陣
const t = new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
movementX, movementY, 0, 1
]);
reDraw($app, t, true);
})
document.addEventListener("mouseup", () => {
isDrag = false;
});
$app.addEventListener("wheel", (e) => {
const {clientX, clientY, deltaY } = e;
const currSacle = 1 + (deltaY < 0 ? 0.1 : -0.1);
const zoom = Math.max(currSacle > 0 ? currSacle : 1, 0.1);
const x = (clientX + window.scrollX) * (1 - zoom);
const y = (clientY + window.scrollY) * (1 - zoom);
const t = new Float32Array([
zoom, 0, 0, 0,
0, zoom, 0, 0,
0, 0, 1, 0,
x, y, 0, 1,
]);
reDraw($app, t);
});
});
矩陣同時實現Canvas圖片拖拽和縮放
Canvas圖片拖拽和縮放的邏輯,和普通Div的拖拽和縮放的邏輯基本一致,不一樣的地方在於我們要修改的是Canvas渲染的當前變換的矩陣,初始時為單位矩陣,我們只需要進行對應的矩陣變換,設定新的變換矩陣,交給Canvas底層渲染即可。具體程式碼如下:
<!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>
<style>
body {
position: relative;
background-color: black;
min-height: 1000px;
margin: 0;
padding: 0;
}
#app {
border:1px solid white;
}
</style>
</head>
<body>
<canvas id="app" width="640" height="340"></canvas>
<script src="./gl-matrix-min.js"></script>
<script src="./index.js"></script>
</body>
</html>
// index.js
document.addEventListener("DOMContentLoaded", () => {
const $app = document.querySelector(`#app`);
const {width, height} = $app.getBoundingClientRect();
const ctx = $app.getContext("2d");
const $img = document.createElement("img");
$img.onload = () => {
ctx.drawImage($img, 0, 0);
};
$img.src = "./01.png";
let isDrag = false;
let ov = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
]);
function reDraw(ctx, o, t) {
const out = new Float32Array([
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
0, 0, 0, 0,
]);
const nv = glMatrix.mat4.multiply(out, t, o);
ctx.save();
ctx.clearRect(0, 0, width, height);
ctx.transform(nv[0], nv[4], nv[1], nv[5], nv[12], nv[13]);
ctx.drawImage($img, 0, 0);
ctx.restore();
return nv;
}
$app.addEventListener("mousedown", (e) => {
isDrag = true;
});
document.addEventListener("mousemove", (e) => {
if (!isDrag) {
return;
}
const {movementX, movementY} = e;
const t = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
movementX, movementY, 0, 1,
]);
ov = reDraw(ctx, ov, t);
});
document.addEventListener("mouseup", (e) => {
isDrag = false;
});
$app.addEventListener("wheel", (e) => {
const {clientX, clientY, deltaY } = e;
const currSacle = 1 + (deltaY < 0 ? 0.1 : -0.1);
const zoom = Math.max(currSacle > 0 ? currSacle : 1, 0.1);
const x = clientX * (1 - zoom);
const y = clientY * (1 - zoom);
const t = new Float32Array([
zoom, 0, 0, 0,
0, zoom, 0, 0,
0, 0, 1, 0,
x, y, 0, 1,
]);
ov = reDraw(ctx, ov, t);
});
});
結束語
這是一個關於線性代數
在前端中運用的系列文章,接下來會分享線性代數更多的實用文章。
由於本人的數學水平一般,行文中難免有錯誤的地方,寫這片文章的意義更多的是進行知識整理,方便日後回顧,如果能夠引起你對數學在前端中運用的興趣,那就更加好了,特別是對於和我一樣的後臺管理系統表單前端工程師
,在表單
之外尋找到其他的樂趣。
如果大家想要獲得樣例中完整的原始碼,可以微信搜尋前端列車長
,關注後回覆20220222
,即可獲得原始碼連結,我們下次再見!