SVG 可以看作是一个无限大小的画布,而 viewBox
属性就是决定将画布中的哪一部分展示出来,viewBox
指定的这个范围称之为视口。viewBox
通过四个属性来定义视口:
- 左上角坐标
- 视口大小
这里使用的坐标均是 SVG 中的坐标系统,SVG 坐标系统中的一个单位长度与一像素并不相等,二者之间的换算关系取决于视口的大小与 SVG 元素的 CSS 大小
<svg width="200" height="200" viewBox="0 0 100 100"></svg>
如上 SVG 的 CSS 大小为 像素,而视口的大小为 SVG 单位,也就是说 SVG 单位等价于 像素,所以一个单位等两个像素大小。
实际上 轴和 轴方向可以有不同的换算比例,为了简单,只考虑二者的换算比例相同。
通过改变视口的左上角坐标即可实现画布拖拽,而改变 SVG 单位与像素单位的比例即可实现缩放。
拖拽
考虑点 ,要实现在视口上移动了 像素和 像素,求 和
考虑简单的情况,没有缩放,那么一像素等与一个 SVG 单位,那么有
得到
现在考虑缩放的情况,此时画布的缩放比例为 ,也就是说一个 SVG 单位等于 像素,换句话说,一个像素大小为 个 SVG 单位,那么
得到
缩放
要实现缩放,只需要改变视口大小与 CSS 大小的比例关系即可。设 SVG 的 CSS 宽高为,要实现 倍的缩放,那么只要将视口的宽度和高度设置为 即可。
如果我们希望在进行缩放时,某个点相对于视口保持不变呢,考虑一个场景,在滚动滚动时我们缩放画布,但是希望此时鼠标所在的点相对于视口是不变的
假设以点 进行缩放,在缩放完成后,点 相对于画布应保持不变,即该点始终距离画布左侧为 像素,距离画布顶部 像素在缩放前后都是一样的。
设缩放前的缩放比例为 ,缩放后的缩放比例为 ,缩放前左上角的坐标为 ,求缩放后左上角的坐标 。
考虑 方向,可以得到两个等式
解此方程可以得到
同理可以得到
所以为了在缩放的过程中保持点的位置不变,除了需要将视口的大小设置为 ,还需要将视口左上角坐标调整为 。
const { useRef, useEffect } = React;
let zoom = 1;
let minX = 0;
let minY = 0;
let startPosition = null;
const Component = () => {
const containerRef = useRef(null);
const svgRef = useRef(null);
useEffect(() => {
const boardContainer = containerRef.current;
const board = svgRef.current;
if (!boardContainer || !board) return;
const { width, height } = boardContainer.getBoundingClientRect();
board.setAttribute('viewBox', `${minX} ${minY} ${width / zoom} ${height /zoom}`);
const handlePointerDown = (e) => {
startPosition = {
x: e.clientX,
y: e.clientY,
}
}
const handlePointerMove = (e) => {
if (!startPosition || !boardContainer || !board) 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}`);
}
const handlePointerUp = (e) => {
if (!startPosition || !boardContainer || !board) 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}`);
minX = newMinX;
minY = newMinY;
startPosition = null;
}
const handleWheel = (e) => {
if (!boardContainer || !board) return;
e.preventDefault();
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;
}
boardContainer.addEventListener('pointerdown', handlePointerDown);
boardContainer.addEventListener('pointermove', handlePointerMove);
boardContainer.addEventListener('pointerup', handlePointerUp);
boardContainer.addEventListener('wheel', handleWheel);
return () => {
if (boardContainer) {
boardContainer.removeEventListener('pointerdown', handlePointerDown);
boardContainer.removeEventListener('pointermove', handlePointerMove);
boardContainer.removeEventListener('pointerup', handlePointerUp);
boardContainer.removeEventListener('wheel', handleWheel);
}
}
}, [])
return (
<div ref={containerRef} style={{ width: '100%', height: 500, border: '2px dashed #aaa' }}>
<svg ref={svgRef} width="100%" height="100%">
<rect x="100" y="100" width="200" height="100" fill="#e77c8e" fill-opacity="0.5" />
</svg>
</div>
)
}