简介
在前端开发中,有些时候会遇到根据鼠标当前位置为原点,滚动滚轮实现图片、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
,即可获得源代码链接,我们下次再见!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。