19
头图

简介

在前端开发中,有些时候会遇到根据鼠标当前位置为原点,滚动滚轮实现图片、canvas、DOM元素缩放的需求。有些同学可能觉得有点难,但其实借助线性代数中的矩阵运算,可以非常容易地实现这一功能,更重要的是,数学作为一门学科,具有通用性,与具体的编程语言和环境无关,掌握好原理便可以实现通用性。

鼠标滚轮缩放元素和图片

缩放的本质

缩放的本质是矩阵变换。

当我们想缩放一个Div元素的时候,一般来说我们可以将其看成是对一个矩形的缩放。为了便于理解,我们这里以一个最简单的矩形的缩放为例子。如下图我们假定有一个边长都为4的矩形,我们以它的中心为原点,建立二维XY坐标轴,可以得到如下图:

01.png

当我们将矩形放大2倍,会得到一个边长都为8的矩形,继续以中心为原点,建立二维XY坐标轴,可以得到下图:

02.png

如果我们对这两张图的图形坐标点进行数学抽象,便可以得到以下两个矩阵:

矩阵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矩阵乘法的介绍,辅助大家理解和回忆矩阵乘法的具体细节。

矩阵乘法.jpg

矩阵变换

当讨论变换时,在数学上一般用到函数(也称映射),即接受输入,产生输出。我们可以把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,如下图:

(3,0)和(-3,0).png

将两个坐标点\(\left( -3,0 \right)\)和\(\left( 3,0 \right)\)以原点为中心、沿着\(X轴\)放大2倍延伸,可以得到新坐标点\(\left( -6,0 \right)\)和\(\left( 6,0 \right)\),它们之间的距离为12,如下图:

(6,0)和(-6,0).png

如果要保持放大后,维持两个坐标点的距离为12个单位,而\(X轴\)正方向那个坐标点的位置不变,那么我们需要在放大后,将两个坐标点沿着\(X轴\)向左平移3个单位,即-3,如下图:

(3,0)和(-9,0).png

观察可得:

$$ -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>

布局效果如下:

布局效果.png

首先我们需要获得关于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;`);
    });
});

效果如下图:

鼠标原点缩放.gif

矩阵实现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);
    });
});

canvas鼠标原点缩放.gif

结束语

这是一个关于线性代数在前端中运用的系列文章,接下来会分享线性代数更多的实用文章。

由于本人的数学水平一般,行文中难免有错误的地方,写这片文章的意义更多的是进行知识整理,方便日后回顾,如果能够引起你对数学在前端中运用的兴趣,那就更加好了,特别是对于和我一样的后台管理系统表单前端工程师,在表单之外寻找到其他的乐趣。

如果大家想要获得样例中完整的源代码,可以微信搜索前端列车长,关注后回复20220222,即可获得源代码链接,我们下次再见!


而井
851 声望1.8k 粉丝