[技術分享] 實做 SVG 中的位移與縮放(SVG Translate and Zoom Scale)-拖曳與縮放功能實做(下)
本篇文章同步刊載於 PJCHENder 前端網頁資源站
最後,我們要來實做出針對 SVG 這個"畫布"本身進行縮放和拖拉的效果(我們拖拉的是整個 SVG 元素,而不是 SVG 當中的各個圖案)。我們先談位移,接著再來說明縮放的功能。其實如果你瞭解 viewBox 的話,你就可以大概知道怎麼進行縮放和拖移了,因為透過設定 viewBox 中 <min-x> <min-y> <width> <height>
這四個不同的屬性值,我們就可以實做出陀拖曳和縮放的效果,而在實做過程中,比較容以卡住的地方,會是在 SVG 座標系和 viewport 座標系的轉換間,因為 SVG 座標系的單位是可以彈性改變的。
SVG 拖拉功能:觀念
我試著用這張圖來說明整個 SVG 畫布在拖移的觀念。
將 SVG 畫布從左邊拖到右邊
為了方便觀念的理解,我們先把情況簡化,在這裡我們只做水平的位移,所以會變的只有 viewBox 中的 x-min。再次強調,雖然這張圖看起來很像是拖曳這個圓點(<circle></circle>
),但實際上我們拖曳的是整個 SVG 元素(<svg></svg>
)。
拖曳的過程其實只是 viewBox 中 x-min 的改變,但為了要計算 x-min 應該要給它多少,我們會需要幾個的一連串步驟。我用下面這張圖來說明要進行的步驟,圓形當中標示的是步驟的順序。
- 為了之後座標系統的轉換,我們要先取的當前 SVG 元素的 viewBox 值,假設一開始的值是(x, y, w, h),這裡我們只需關注的 x ,簡稱 viewBoxX0。
- 透過上一篇所說明的,這時候我們可以取得滑鼠點擊下去時的 clientX 的值,簡稱為 clientX0,這裡對應到的值是 10。
- 透過上一篇所說明的,我們可以把 clientX 的值轉成 SVG 座標系統中的值,簡稱為 svgX0,這裡對應到的值是 20。
- 接著按住滑鼠拖動畫布,在移動的過程中,可以得到當前滑鼠座標的 clientX 值,簡稱為 clientX1,這裡對應到的值是 20。
- 同樣的,我們可以把 clientX 的值轉成 SVG 座標系統中的值,簡稱為 svgX1,這裡對應到值是 40。
- 我們可以計算滑鼠拖拉時,在 SVG 座標系統中的位移(svgX0 - svgX1),這裡就會是 (20 - 40)。
- 我們可以得到最終 viewBox x 要代入的值就是
viewBoxX0 + (svgX0 - svgX1)
,也就是 viewBoxX0 + (20 - 40),意義上來說,就是將 SVG 座標系統整個向右移動 20 單位,所以整個 SVG 座標就向右移動了 20 單位。
這個部分可能需要多揣摩一下。同理,我們也可以知道,如果有進行上下拖拉的話,viewBox 的 y 會改變,而最終 viewBox 中 y 的值就是 viewBoxY0 + (svgY0 - svgY1)
。
總結一下:
在 viewBox 中 x 最後要代入的值為 viewBoxX1 = viewBoxX0 + (svgX1 - svgX2)
。
在 viewBox 中 y 最後要代入的值為 viewBoxY1 = viewBoxY0 + (svgY1 - svgY2)
。
再來我們就可以把這樣的觀念實做成程式碼了。
SVG 拖拉功能:實做
把上面的文字轉成程式碼的話,會長的像這樣子:
/*
開始:滑鼠拖拉的效果
*/
let moving;
// 滑鼠點下,開始拖拉
function mouseDown(e) {
moving = true;
}
// 拖拉的移動過程
function drag(e) {
if (moving === true) {
// 1. 取得一開始的 viewBox 值,原本是字串,拆成陣列,方便之後運算
let startViewBox = svg
.getAttribute('viewBox')
.split(' ')
.map((n) => parseFloat(n));
// 2. 取得滑鼠當前 viewport 中 client 座標值
let startClient = {
x: e.clientX,
y: e.clientY,
};
// 3. 計算對應回去的 SVG 座標值
let newSVGPoint = svg.createSVGPoint();
let CTM = svg.getScreenCTM();
newSVGPoint.x = startClient.x;
newSVGPoint.y = startClient.y;
let startSVGPoint = newSVGPoint.matrixTransform(CTM.inverse());
// 4. 計算拖曳後滑鼠所在的 viewport client 座標值
let moveToClient = {
x: e.clientX + e.movementX,
y: e.clientY + e.movementY,
};
// 5. 計算對應回去的 SVG 座標值
newSVGPoint = svg.createSVGPoint();
CTM = svg.getScreenCTM();
newSVGPoint.x = moveToClient.x;
newSVGPoint.y = moveToClient.y;
let moveToSVGPoint = newSVGPoint.matrixTransform(CTM.inverse());
// 6. 計算位移量
let delta = {
dx: startSVGPoint.x - moveToSVGPoint.x,
dy: startSVGPoint.y - moveToSVGPoint.y,
};
// 7. 設定新的 viewBox 值
let moveToViewBox = `${startViewBox[0] + delta.dx} ${startViewBox[1] + delta.dy} ${
startViewBox[2]
} ${startViewBox[3]}`;
svg.setAttribute('viewBox', moveToViewBox);
}
}
// 滑鼠點擊結束(拖曳結束)
function mouseUp() {
moving = false;
} // 結束:滑鼠拖拉的效果
最後要記得把事件綁在 svg 元素上:
svg.addEventListener('mousedown', mouseDown, false);
svg.addEventListener('mousemove', drag, false);
svg.addEventListener('mouseup', mouseUp, false);
這樣就可以讓自由的拖動整個 SVG 元素了。
SVG 縮放功能:觀念
縮放的作法其實一樣是透過去改變 viewBox 的設定值,只是剛剛改變的是 <min-x>
和 min-y
,而縮放要改變的是 <width>
和 <height>
。
另外,前幾篇文章中有提到一個很重要的觀念,就是透過 viewBox 來縮放時,實際上它是從整個 SVG 元素的左上角(下圖中橘色點)進行縮放。
縮放時是以 SVG 左上角為中心點進行縮放,也就是橘色點
讓我們利用下面這張圖更清楚的看看為什麼在縮放的過程中,一開始的圓點為什麼會跑到那個位置。為了簡化理解,我們還是先只看 X 軸,在一開始的時候,SVG 座標系統中的 1 單 位等於 viewport 座標系統中的 1px,圓點對應回去的 clientX 是 10,利用 CTM 將座標系統轉換換會得到 SVG X 是 20 ;在縮放的過程中,會以 SVG 元素的左上角為原點進行縮放;縮放後整個 SVG 座標系統的單位尺寸就改變了,在這裡因為我放大兩倍,所以 SVG 座標系統的 1 單位會變成 viewport 座標系統中的 2px,但是圓點的 SVG x 仍然是 20 。
就是因為:
- 以 SVG 左上角原點做縮放(左上角這個原點其實會是當時 viewBox 的 min-x 和 min-y 的值。)
- SVG 座標系統對應回 viewport 座標系統的單位尺寸已經改變。 所以這個圓點就在放大的過程中被往右下方移動了(放大兩倍是利用讓 viewBox 的 width/2 和 height/2)。
一開始圓點的 svgX 是 10,縮放後仍然是 10,但是對於 viewport 的 clientX 改變了
這個情況會使得我們點擊的點,在縮放的過程中,相對於 viewport Client 的座標值會一直改變(如下圖中圓圈 1)。
為了不讓使用者在縮放時,覺得我們點擊的那個點離我們越來越遠,所以我們在縮放完後要再把這個點搬回使用者點擊的那個起始點,讓使用者感覺這個點看起來想是在原地縮放(如下圖中圓圈 2)。
縮放的過程中,我們點擊的點相對於 viewport Client 的座標值會一直改變,為了讓使用者感覺是在圓點縮放,需要把放大後的點移回原本被縮放的點
現在知道觀念後,可以試著把實做的步驟列出來,整個流程會像這樣子:
縮放實做觀念流程
- 取得一開始的 viewBox ,這樣我們才知道要以什麼作為縮放的依據,簡稱 viewBox0,其中的 width 簡稱為 viewBox0.width。
- 取得滑鼠執行縮放位置的 viewPort Client 座標,並利用 CTM 對應取得 SVG 座標,簡稱 clientX0 和 svgX0。
- 進行縮放,如果要讓原本的尺寸縮放兩倍的話,width 和 height 就要除以兩倍;簡稱 r 為我們縮放的倍率、縮放後的 viewBox 為 viewBox 1;所以
viewBox1.width = viewBox1.width / r
,viewBox1.height = viewBox1.height / r
。 - 將一開始滑鼠的執行縮放位置的 viewPort Client 座標(也就是 clientX1),利用新的 CTM (CTM 每次只要縮放或位移後都會改變),轉換出對應的 SVG 座標,簡稱 svgX1。
- 取得在縮放過程中該圓點的位移量
(svgX0 - svgX1)
。 - 得到最終的 viewBox2 為,
viewBoxX2 = viewBoxX0 + (svgX0 - svgX1)
,同理viewBoxY2 = viewBoxY0 + (svgY0 - svgY1)
。
觀念說明完了,程式碼就能夠打出來了 XD
SVG 縮放功能:實做
實做的程式碼如下:
/*
開始:滑鼠縮放的效果
*/
function zoom(e) {
// 1.取得一開始的 viewBox。
let startViewBox = svg
.getAttribute('viewBox')
.split(' ')
.map((n) => parseFloat(n));
// 2.取得滑鼠執行縮放位置的 viewPort Client 座標,並利用 CTM 對應取得 SVG 座標。
// 2.1 取得滑鼠執行縮放的位置
let startClient = {
x: e.clientX,
y: e.clientY,
};
// 2.2 轉換成 SVG 座標系統中的 SVG 座標點
let newSVGPoint = svg.createSVGPoint();
let CTM = svg.getScreenCTM();
newSVGPoint.x = startClient.x;
newSVGPoint.y = startClient.y;
let startSVGPoint = newSVGPoint.matrixTransform(CTM.inverse());
// 3.進行縮放,如果要讓原本的尺寸縮放兩倍的話。
// 3.1 設定縮放倍率
let r;
if (e.deltaY > 0) {
r = 0.9;
} else if (e.deltaY < 0) {
r = 1.1;
} else {
r = 1;
}
// 3.2 進行縮放
svg.setAttribute(
'viewBox',
`${startViewBox[0]} ${startViewBox[1]} ${startViewBox[2] * r} ${startViewBox[3] * r}`,
);
// 4.將一開始滑鼠的執行縮放位置的 viewPort Client 座標利用新的 CTM ,轉換出對應的 SVG 座標。
CTM = svg.getScreenCTM();
let moveToSVGPoint = newSVGPoint.matrixTransform(CTM.inverse());
// 5.取得在縮放過程中該圓點的位移量 `(svgX0 - svgX1)`。
let delta = {
dx: startSVGPoint.x - moveToSVGPoint.x,
dy: startSVGPoint.y - moveToSVGPoint.y,
};
// 6.設定最終的 viewBox2
let middleViewBox = svg
.getAttribute('viewBox')
.split(' ')
.map((n) => parseFloat(n));
let moveBackViewBox = `${middleViewBox[0] + delta.dx} ${middleViewBox[1] + delta.dy} ${
middleViewBox[2]
} ${middleViewBox[3]}`;
svg.setAttribute('viewBox', moveBackViewBox);
// 更新 viewBox 資訊
showViewBox();
} // 結束:滑鼠縮放的效果
最後,一樣要記得把事件綁上去:
svg.addEventListener('wheel', zoom, false);
後記
這三篇文章的產生整理了實做 SVG 縮放和拖移的作法,花了相當多時間在理解並和同事討論激盪了許久,希望能夠讓同樣碰到這塊的人,能夠花比較少的時間就瞭解背後的原理和過程。
原本希望能夠用簡單易懂的方 式讓大家都能夠瞭解並實做出 SVG 的拖曳和縮放,但實際上在說明的時候還是免不了要用到很多數學上位移的觀念,而且 viewBox 這個東西也需要自己花一些時間去玩它和感覺它。
希望這篇文章能夠對你有幫助,若有問題也歡迎留言,作法也許不是最好的,觀念也許有些瑕疵,但都歡迎留言討論或提供建議。
SVG 應用學習資源
- Understanding SVG Coordinate Systems and Transformations 原文@ sarasoueidan; 中譯@ Andyyou
- How to Scale SVG @ CSS-Tricks
- SVG 研究之路 @ OXXO
- SVGPoint @ MDN