2017年3月31日 星期五

[技術分享] 實做 SVG 中的位移與縮放(SVG Translate and Zoom Scale)-拖曳與縮放功能實做(下)




最後,我們要來實做出針對 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 拖拉效果步驟

1. 為了之後座標系統的轉換,我們要先取的當前 SVG 元素的 viewBox 值,假設一開始的值是(x, y, w, h),這裡我們只需關注的 x ,簡稱viewBoxX0。

2. 透過上一篇所說明的,這時候我們可以取得滑鼠點擊下去時的 clientX 的值,簡稱為 clientX0,這裡對應到的值是 10。

3. 透過上一篇所說明的,我們可以把 clientX 的值轉成 SVG 座標系統中的值,簡稱為 svgX0,這裡對應到的值是 20。

4. 接著按住滑鼠拖動畫布,在移動的過程中,可以得到當前滑鼠座標的 clientX 值,簡稱為 clientX1,這裡對應到的值是 20。

5. 同樣的,我們可以把 clientX 的值轉成 SVG 座標系統中的值,簡稱為 svgX1,這裡對應到值是 40。

6. 我們可以計算滑鼠拖拉時,在 SVG 座標系統中的位移(svgX0 - svgX1),這裡就會是 (20 - 40)。

7. 我們可以得到最終 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, //  movement 可以取得滑鼠位移量
      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)
    console.log(moveToViewBox)
  }
}
//  滑鼠點擊結束(拖曳結束)
function mouseUp(){
    moving = false
    showViewBox()
} //  結束:滑鼠拖拉的效果
  

最後要記得把事件綁在 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 。

就是因為:
1. 以 SVG 左上角原點做縮放(左上角這個原點其實會是當時 viewBox 的 min-x 和 min-y 的值。)
2. SVG 座標系統對應回 viewport 座標系統的單位尺寸已經改變。
所以這個圓點就在放大的過程中被往右下方移動了(放大兩倍是利用讓 viewBox 的 width/2 和 height/2)。

一開始圓點的 svgX 是 20,縮放後仍然是20,但是對於 viewport 的 clientX 改變了

這個情況會使得我們點擊的點,在縮放的過程中,相對於 viewport Client 的座標值會一直改變(如下圖中圓圈 1)。

為了不讓使用者在縮放時,覺得我們點擊的那個點離我們越來越遠,所以我們在縮放完後要再把這個點搬回使用者點擊的那個起始點,讓使用者感覺這個點看起來想是在原地縮放(如下圖中圓圈 2)。

縮放的過程中,我們點擊的點相對於 viewport Client 的座標值會一直改變,為了讓使用者感覺是在圓點縮放,需要把放大後的點移回原本被縮放的點

現在知道觀念後,可以試著把實做的步驟列出來,整個流程會像這樣子:

縮放實做觀念流程

1. 取得一開始的 viewBox ,這樣我們才知道要以什麼作為縮放的依據,簡稱 viewBox0,其中的 width 簡稱為 viewBox0.width。

2. 取得滑鼠執行縮放位置的 viewPort Client 座標,並利用 CTM 對應取得 SVG 座標,簡稱 clientX0 和 svgX0。

3. 進行縮放,如果要讓原本的尺寸縮放兩倍的話,width 和 height 就要除以兩倍;簡稱 r 為我們縮放的倍率、縮放後的 viewBox 為 viewBox 1;所以 viewBox1.width = viewBox1.width / rviewBox1.height = viewBox1.height / r

4. 將一開始滑鼠的執行縮放位置的 viewPort Client 座標(也就是 clientX1),利用新的 CTM (CTM 每次只要縮放或位移後都會改變),轉換出對應的 SVG 座標,簡稱 svgX1。

5. 取得在縮放過程中該圓點的位移量 (svgX0 - svgX1)

6. 得到最終的 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 應用學習資源


最後列出一些關於 SVG 我覺得還不錯的資源

Share:

0 意見:

張貼留言