基于 SVG 无限画板的拖拽和缩放实现

331 阅读1分钟

动画.gif

SVG 可以看作是一个无限大小的画布,而 viewBox 属性就是决定将画布中的哪一部分展示出来,viewBox 指定的这个范围称之为视口。viewBox 通过四个属性来定义视口:

  • 左上角坐标 (minX,minY)(\text{minX}, \text{minY})
  • 视口大小 (width,height)(\text{width}, \text{height})

这里使用的坐标均是 SVG 中的坐标系统,SVG 坐标系统中的一个单位长度与一像素并不相等,二者之间的换算关系取决于视口的大小与 SVG 元素的 CSS 大小

<svg width="200" height="200" viewBox="0 0 100 100"></svg>

如上 SVG 的 CSS 大小为 200200 像素,而视口的大小为 100100 SVG 单位,也就是说 100100 SVG 单位等价于 200200 像素,所以一个单位等两个像素大小。

实际上 xx 轴和 yy 轴方向可以有不同的换算比例,为了简单,只考虑二者的换算比例相同。

通过改变视口的左上角坐标即可实现画布拖拽,而改变 SVG 单位与像素单位的比例即可实现缩放。

拖拽

考虑点 (x,y)(x, y),要实现在视口上移动了 offsetX\text{offsetX} 像素和 offsetY\text{offsetY} 像素,求 newMinX\text{newMinX}newMinY\text{newMinY}

考虑简单的情况,没有缩放,那么一像素等与一个 SVG 单位,那么有

x2x1=(xnewMinX)(xminX)=offsetXy2y1=(ynewMinY)(yminY)=offsetY\begin{aligned} x_2 - x_1 &= (x - \text{newMinX}) - (x - \text{minX}) &= \text{offsetX} \\ y_2 - y_1 &= (y - \text{newMinY}) - (y - \text{minY}) &= \text{offsetY} \end{aligned}

得到

newMinX=minXoffsetXnewMinY=minYoffsetY\begin{aligned} \text{newMinX} &= \text{minX} - \text{offsetX} \\ \text{newMinY} &= \text{minY} - \text{offsetY} \end{aligned}

现在考虑缩放的情况,此时画布的缩放比例为 zoom\text{zoom},也就是说一个 SVG 单位等于 zoom\text{zoom} 像素,换句话说,一个像素大小为 1zoom\dfrac{1}{\text{zoom}} 个 SVG 单位,那么

x2x1=(xnewMinX)(xminX)=offsetXzoomy2y1=(ynewMinY)(yminY)=offsetYzoom\begin{aligned} x_2 - x_1 &= (x - \text{newMinX}) - (x - \text{minX}) &= \cfrac{\text{offsetX}}{\text{zoom}} \\ y_2 - y_1 &= (y - \text{newMinY}) - (y - \text{minY}) &= \cfrac{\text{offsetY}}{\text{zoom}} \end{aligned}

得到

newMinX=minXoffsetXzoomnewMinY=minYoffsetYzoom\begin{aligned} \text{newMinX} &= \text{minX} - \cfrac{\text{offsetX}}{\text{zoom}} \\ \text{newMinY} &= \text{minY} - \cfrac{\text{offsetY}}{\text{zoom}} \end{aligned}

缩放

要实现缩放,只需要改变视口大小与 CSS 大小的比例关系即可。设 SVG 的 CSS 宽高为(width,height)(\text{width}, \text{height}),要实现 zoom\text{zoom} 倍的缩放,那么只要将视口的宽度和高度设置为 (widthzoom,heightzoom)(\dfrac{\text{width}}{\text{zoom}}, \dfrac{\text{height}}{\text{zoom}})即可。

如果我们希望在进行缩放时,某个点相对于视口保持不变。考虑一个场景,在滚动滚动时我们缩放画布,希望此时鼠标所在的点相对于视口是不变的

假设以点 (x,y)(x, y) 进行缩放,在缩放完成后,点 (x,y)(x, y) 相对于画布应保持不变,即该点始终距离画布左侧为 offsetX\text{offsetX} 像素,距离画布顶部 offsetY\text{offsetY} 像素,在缩放前后都是一样的。

设缩放前的缩放比例为 zoom\text{zoom},缩放后的缩放比例为 newZoom\text{newZoom},缩放前左上角的坐标为 (minX,minY)(\text{minX}, \text{minY}),求缩放后左上角的坐标 (newMinX,newMinY)(\text{newMinX}, \text{newMinY})

考虑 xx 方向,可以得到两个等式

(xminX)zoom=offsetX(xnewMinX)newZoom=offsetX\begin{aligned} (x - \text{minX}) \cdot \text{zoom} &= \text{offsetX} \\ (x - \text{newMinX}) \cdot \text{newZoom} &= \text{offsetX} \\ \end{aligned}

解此方程可以得到

newMinX=offsetX(1zoom1newZoom)+minX\text{newMinX} = \text{offsetX} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minX}

同理可以得到

newMinY=offsetY(1zoom1newZoom)+minY\text{newMinY} = \text{offsetY} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minY}

所以为了在缩放的过程中保持点的位置不变,除了需要将视口的大小设置为 (widthnewZoom,heightnewZoom)(\cfrac{\text{width}}{\text{newZoom}}, \cfrac{\text{height}}{\text{newZoom}}),还需要将视口左上角坐标调整为 (offsetX(1zoom1newZoom)+minX,offsetY(1zoom1newZoom)+minY)(\text{offsetX} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minX}, \text{offsetY} \cdot (\cfrac{1}{\text{zoom}} - \cfrac{1}{\text{newZoom}}) + \text{minY})

源码

以下是文章开头动图的实现源码

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        body {
            padding: 0;
            margin: 0;
        }
        .board-container {
            width: 100vw;
            height: 100vh;
            overflow: hidden;
        }
    </style>
</head>
<body>
    <div class="board-container">
        <svg id="board" width="100%" height="100%">
            <rect x="100" y="100" width="200" height="100" fill="#e77c8e" fill-opacity="0.5" />
        </svg>
    </div>

    <script>
        let zoom = 1;
        let minX = 0;
        let minY = 0;
        let startPosition = null;
        
        const boardContainer = document.querySelector('.board-container');
        const board = document.getElementById('board');
        const { width, height } = boardContainer.getBoundingClientRect();
        board.setAttribute('viewBox', `${minX} ${minY} ${width / zoom} ${height /zoom}`);

        boardContainer.addEventListener('pointerdown', (e) => {
           startPosition = {
               x: e.clientX,
               y: e.clientY,
           }
        });

        boardContainer.addEventListener('pointermove', (e) => {
            if (!startPosition) return;
            const { width, height } = boardContainer.getBoundingClientRect();
            const endPosition = {
                x: e.clientX,
                y: e.clientY
            }
            const offsetX = endPosition.x - startPosition.x;
            const offsetY = endPosition.y - startPosition.y;
            const newMinX = minX - offsetX / zoom;
            const newMinY = minY - offsetY / zoom;
            board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / zoom} ${height /zoom}`);
        });

        boardContainer.addEventListener('pointerup', (e) => {
            const { width, height } = boardContainer.getBoundingClientRect();
            const endPosition = {
                x: e.clientX,
                y: e.clientY
            }
            const offsetX = endPosition.x - startPosition.x;
            const offsetY = endPosition.y - startPosition.y;
            const newMinX = minX - offsetX / zoom;
            const newMinY = minY - offsetY / zoom;
            board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / zoom} ${height /zoom}`);
            
            minX = newMinX;
            minY = newMinY;
            startPosition = null;
        });

        boardContainer.addEventListener('wheel', (e) => {
            const { width, height, x: containerX, y: containerY } = boardContainer.getBoundingClientRect();

            const x = e.clientX;
            const y = e.clientY;

            const offsetX = x - containerX;
            const offsetY = y - containerY;
            
            let newZoom;
            if (e.deltaY < 0) {
                newZoom = Math.min(zoom * 1.1, 10);
            } else {
                newZoom = Math.max(zoom * 0.9, 0.1);
            }
            const newMinX = minX + offsetX * (1 / zoom - 1 / newZoom);
            const newMinY = minY + offsetY * (1 / zoom - 1 / newZoom);
            board.setAttribute('viewBox', `${newMinX} ${newMinY} ${width / newZoom} ${height / newZoom}`);
            
            zoom = newZoom;
            minX = newMinX;
            minY = newMinY;
        })
    </script>
</body>
</html>