简介
在前端开发中,有些时候会遇到依据鼠标以后地位为原点,滚动滚轮实现图片、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.jsdocument.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
,即可取得源代码链接,咱们下次再见!