简介
在前端开发中,有些时候会遇到依据鼠标以后地位为原点,滚动滚轮实现图片、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- 缩放倍数)
$$
其实上述的过程就是 以以后鼠标点为原点缩放图形
的过程形象,即:先缩放图形,而后把原来的缩放点平移回先前的地位。
4×4 平移矩阵
因为 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
,即可取得源代码链接,咱们下次再见!