简介

在前端开发中,有些时候会遇到依据鼠标以后地位为原点,滚动滚轮实现图片、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为矩阵AB的乘积,记作,其中矩阵C中的第行第列元素能够示意为:

如下所示:

还有一个准则须要特地留神的是:仅当矩阵A列数(column)等于矩阵B行数(row)时,A与B才能够相乘,否则不能矩阵相乘,这一点要切记!因为前面因为这个准则和不便计算,咱们会把4x2矩阵转为4x4矩阵。

为了便于了解,这里截取了《3D数学根底:图形与游戏开发》这本书中对于3x3矩阵乘法的介绍,辅助大家了解和回顾矩阵乘法的具体细节。

矩阵变换

当探讨变换时,在数学上个别用到函数(也称映射),即承受输出,产生输入。咱们能够把abF函数/映射记为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元素以鼠标为原点进行缩放

假设当初页面有一个IDappdiv元素,位于页面两头地位,代码如下:

<!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,即可取得源代码链接,咱们下次再见!