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 我覺得還不錯的資源

2017年3月22日

[技術分享] 利用 SVG 中的 CTM 進行座標系統的轉換(SVG Coordinate System Transform Matrix)-拖曳與縮放功能實做(中)




在這一系列文章中我們把 SVG 整個元素視為一個畫布(Canvas),而不是去 SVG 討論裡面的各個圖形,另外,只討論當 viewport 和 viewBox 兩者比例相等的情況。

在上一篇文章中([教學] 理解 SVG 中的 Viewport 和 ViewBox-拖曳與縮放功能實做(上)),說明到了在 viewport 座標系統中,1px 的大小就會是實際 1px 的大小;但在 viewBox 的 SVG 座標系統中,則可以透過 ViewBox 的設定,去進行縮放和移動,進而使的圖案本身有位移(translate)和縮放(scale)的效果。

為了要讓我們能夠實做出拖拉和縮放的效果,有幾點是我們需要進一步說明的。

實做效果和原始碼


這是我們這一篇文章希望做出來的效果,我們能夠取的 viewport 的座標點,並轉換成 SVG 座標系統中的座標值。另外我另外加了幾個按鈕,讓你可以直接設定 viewBox 並觀察其中圓心的座標會有如何的變化,建議可以在 jsfiddle 中比較方便操作和感受。

詳細的作法會在下面說明:


複習SVG 座標系統和 viewport 座標系統


從上篇文章我們知道 viewport 的座標系統可以視為是相對於瀏覽器不會變動的座標系統(1px 就是 1px),但是 SVG 座標系統則是會變動的,隨著給定不同的 viewBox 值,SVG 座標系統中的 1 個單位以有可能等於、大於或小於 1px。

這三張圖對於這個觀念的理解真的很重要很重要,所以讓我們快速地再看一次(灰色的是 viewport 座標系統;藍色的是 SVG 座標系統):

當 viewBox 等同於 viewport 的情況

當 viewport 等同於 viewBox 時可以看到鸚鵡右下角的藍點不論在 viewport 座標系統中的 offset 座標點或 SVG 座標系統中的座標點,座標值都是(200, 300),你可以對應到上方側和左方側的指標:

viewBox 和 viewport 尺寸大小相同時,offset 座標值等於 svg

當 viewBox 小於 viewport 的情況

當 viewBox 小於 viewport 時(這裡 viewBox = “400 300 0 0”),因為 viewBox 會先被裁切然後填滿整個 viewport ,所以在 SVG 座標系統藍點雖然還是(200, 300),可視實際上 viewport 座標系統中的 offset 座標值對應回去是(400, 600),也就是說在這個情況下 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 2 單位,也就是 2px。

此時 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 2 單位,也就是 2px

當 viewBox 大於 viewport 的情況

當 viewBox 大於 viewport 時(這裡 viewBox = “1600 1200 0 0”),因為 viewBox 會先被裁切然後填滿整個 viewport ,所以在 SVG 座標系統藍點雖然還是(200, 300),可視實際上 viewport 座標系統中對應回去是(100, 150),也就是說在這個情況下 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 0.5 單位,也就是 0.5px。

此時 SVG 座標系統中的 1 單位,會是 viewport 座標系統的 0.5 單位,也就是 0.5px

SVG 座標系統和 viewport 座標系統的轉換:觀念


如果你對於上面的觀念還不清楚的話,建議可以再把第一篇文章看過一次,或在想清楚。接下來我們要說明如何在 SVG 座標系統和 viewport 座標系統中做轉換。

另外,在上一篇文章中,我們有提到 viewBox = "x-min y-min width height" 這四個屬性值,而在上面的範例中,敏銳的讀者應該會發現, 不論是放大或縮小,都是從最左上方的點開始(橘點),這點非常很重要,之後在實做縮放的時候會用到這個觀念。

不論是放大或縮小,都是從最左上方的點開始(橘點)
其實 SVG 座標系統和 viewport 座標系統中是有辦法做相對應的轉換的。在上面的說明中,為了方便比較 viewport 座標系統和 SVG 座標系統單位大小的不同,我們都是用 offset 的座標點,但是如果我們希望能在 viewport 座標系統和 SVG 座標系統間做相對的轉換,我們就會需要用到 client 這個座標名稱。

client 座標和 offset 座標一樣都是屬於 viewport 座標系統,只是前者是以瀏覽器視窗左上角為原點(0, 0);後者則是以容器左上角為原點。
client 座標和 offset 座標一樣都是屬於 viewport 座標系統,只是前者是以瀏覽器視窗左上角為原點(0, 0);後者則是以容器左上角為原點

SVG 座標系統和 viewport 座標系統間的轉換需要一些數學的運算(電腦會幫你算),但為了避免 大家(我)看到數學就害怕(看到數學就先暈一半),我們先用簡單的方式來理解這兩者座標的轉換。

作法其實很簡單,想像有一個叫做 CTM 的魔術棒,透過這個魔術棒,我們就可以將 viewport 座標系統中的 client(x, y) 轉換成 SVG 座標系統中的 svg(x, y);相似地,我們可以透過 CTM 這個魔術幫棒將 SVG 座標系統中的 svg(x, y) 轉換為 viewport 座標系統中的 client(x, y):


更精確的說,當我們要從 SVG 座標系統 → viewport(client) 座標系統時,是利用 CTM X SVG 座標系統中的座標,可以得到 viewport 座標系統中的座標值:


可是如果要從 viewport(client) 座標系統 → SVG 座標系統,那麼我們的 CTM 也要反轉 一下才能得會 SVG 座標。為什麼要把 CTM 反轉過來才能用呢?你可以想像你要用鑰匙開鎖和解鎖,開鎖的時候要順時鐘轉,解鎖的時候要用逆時針轉,CTM 的反轉也可以用這樣的概念來想。


這裡有一點要留意的是兩個座標系統間的轉換時,我們用的是 viewport 座標系統中的 client(x, y) 而不是 offset(x, y),這點要非常留意

CTM 的真面貌


好吧!一下魔術棒,一下要鑰匙的,你可能覺得很煩,我就直接說了,其實 CTM 就是個 3 x 3 的矩陣,全名是 current transform matrix。什麼…!!矩陣…!!那我需要把高中的數學課本拿出來嗎??


就說用魔術棒想會比較好吧,其實,如果你只是想要瞭解基本的位移和縮放,只要瞭解 viewBox 裡面的四個屬性值,知道有魔術棒這個東西可以幫助我們把座標點從 viewport(client) 座標系統和 SVG 座標間做轉換就好了,還有很重要的一點就是是,每次我們透過設定 viewBox 去對 SVG 進行縮放或位移後,魔術棒(CTM)的樣式都會變的不一樣這樣就可以了。

但是如果你想要更深入瞭解 SVG 的各種變化,像是選旋轉(rotate)、歪斜(skewed)或者應用到 SVG 當中的各個元素的話(例如,<circle></circle>、<path></path>),那你可能就需要更深入的瞭解 CTM 矩陣。

至於為什麼魔術棒需要像鑰匙一樣向左轉、向右轉,其實就是反矩陣的概念。

為了閱讀上的舒適度,我會把實際上矩陣轉換的公式寫在文中最後,如果不排斥的話,可以最後再看,或者就先直接當成它是一個魔術棒吧!

至於要如何取得 CTM 這個魔術棒,就讓我們實際看一下 code 吧!

SVG 座標系統和 viewport 座標系統的轉換:實做


接下來我們會看到許多 JavaScrip Web API 來協助我們進行 SVG 的操作,但是要注意的是,在這裡我們都是針對 SVG 這個元素(也就是 ,<svg></svg> 個 tag 本身),而不是針對 svg 裡面的圖案(例如 `<circle></circle>, <line></line> 等等)進行操作。

在 JavaScript Web API 中可以直接透過 SVGGraphicsElement.getScreenCTM() 這個方法,只要針對某個 svg 元素就可以讓我們直接取得 CTM,那麼到底要如何實際易應用呢?

取得 viewport 座標系統的座標值(clientXY, offsetXY)

我們先在 HTML 建立 SVG 元素:
  
<svg id="svg" width="600" height="300" viewBox="0 0 600 300">
  <rect x="0" y="0" fill="none" stroke="#EB7B2D" stroke-width="2" width="600" height="300" />
  <circle cx="300" cy="150" r="5" fill="#EB7B2D" />
</svg>

<!-- 取得滑鼠座標資訊 -->
<div id="info">
  <p id="offset">offset</p>
  <p id="client">client</p>
  <p id="showSvg">svg</p>
</div>
  
透過 <svg width="600" height="300">,我們定義了 viewport 為 600 x 300(px)。利用 <circle></circle>,我們在 SVG 中畫了一個圓心在(300, 150)、半徑(r)為 5 的圓,要注意的是,這裡的(300, 150)指的是 SVG 座標系統。

另外利用 <rect x="0" y="0" fill="none" stroke="#EB7B2D" stroke-width="2" width="600" height="300" /> 也在 SVG 中幫我們畫一個邊框,這能夠幫助我們看之後 SVG 座標系統的位移和縮放。

接著,我們利用 JS 來取得 viewport 座標系統中的 offset 和 client 座標值,並且希望滑鼠滑動的時候可以馬上取得該滑鼠所在的 viewport 座標值,因此我們在 svg 這個元素上把上 mousemove 事件 svg.addEventListener('mousemove', reportCurrentPoint),當滑鼠一滑動,就觸發去執行 reportCurrentPoint 這個函式:
  
function reportCurrentPoint (e) {
    //  選取 HTML 各元素
    const info = document.getElementById('info')
    const offset = document.getElementById('offset')
    const client = document.getElementById('client')
    const showSvg = document.getElementById('showSvg')
  
    //    取得 viewport 座標系統中的 offset 和 client 座標值
    offset.textContent = `offset (${e.offsetX}, ${e.offsetY})`
    client.textContent = `client (${e.clientX}, ${e.clientY})`
}
const svg = document.getElementById('svg')
svg.addEventListener('mousemove', reportCurrentPoint)
  

利用則是利用 ES6 中模版字串符的方式代入 offset 和 client 座標值。

在我們的範例中,你會發現 offset 和 client 的值相差 9px ,當我們把滑鼠移到這個 container 的最左上角時,你會發現 offset(0, 0),client(9, 9),也就是從 container 左上角和 window 左上角的差值。

(註:一般來說 client 的值會以瀏覽器視窗左上角的圓點,但在示範所用的 fiddle 中,因為它是分割視窗的緣故,所以是以Result 視窗的左上角為原點)

取的 SVG 座標系統中的座標值

接著我們要寫 JS ,要在座標系統中的座標可以彼此轉換,我們要先在 SVG 座標系統中建立一個點,可以直接使用 SVGElement.createSVGPoint() 這個方法,這個點建立起來後會是一個 SVGPoint 物件,包含屬性 x 和屬性 y ,初始值會是(0, 0)。

然後,我們可以利用 SVGGraphicsElement.getScreenCTM() 來取得魔術棒 CTM。
  
const svg = document.getElementById('svg')  //  選取 SVG 元素
let SVGPoint = svg.createSVGPoint()         //  建立一個 SVG 座標,初始值為(0,0)
let CTM = svg.getScreenCTM()                //  取的該元素的 CTM

/*
**  svgPoint(SVG 座標系統) * CTM ---> client (viewport 座標系統)
*/
//  把 SVG 的座標點給進去
SVGPoint.x = 300
SVGPoint.y = 150
clientPoint = SVGPoint.matrixTransform(CTM) //  client(309, 159)

/*
**  client (viewport 座標系統) * CTM^(-1) ---> svgPoint(SVG 座標系統)
*/
SVGPoint = clientPoint.matrixTransform(CTM.inverse())   //  svg(300, 150)
  

SVG 座標系統轉換成 viewport 座標系統的 client 座標點,可以直接使用 CTM。

因此假設我想要知道圓心的 svg(300, 150) 轉換之後在 viewport 的 client 座標是多少,我們可以利用 SVGPoint.x = 300; SVGPoint.y = 150 把座標點給進去,接著讓這個 svg 座標點透過魔術棒 CTM (實際上做的是矩陣相乘,CTM * svg)轉換為 client 座標點,於是寫 clientPoint = SVGPoint.matrixTransform(CTM),我們就能得到 client(309, 159) 這個屬於 viewport 座標系統的座標值。

viewport 座標系統的 client 座標點要轉換為 SVG 座標系統的 SVGPoint時,則使用反轉後的 CTM(CTM的反矩陣)。

我們也可以把剛剛得到的 client(309, 159) 代入反轉過的 CTM 中轉回 SVG 座標系統的座標點(一樣是矩陣相乘,只是被乘的是 CTM 的反矩陣, CTM^(-1) x client),寫法是 SVGPoint = clientPoint.matrixTransform(CTM.inverse()) ,這樣我就會得回 SVG 座標系統中的座標值。

把 SVG 的轉換也綁到滑鼠移動的事件上

一般來說,因為 viewport 座標系統的尺寸是確定的(1px 就是 1px),所以我們比較常利用 client 座標來回推 SVG 座標系統的座標值,也就是 CTM^(-1) x client。

現在,就讓我們把 client 座標轉換為 SVG 座標的寫法綁在 mousemove 這個事件上,讓滑鼠移動的時候,都能夠馬上取得 SVG 座標系統中的座標值。
  
function reportCurrentPoint (e) {
    //  選取 HTML 各元素
    const info = document.getElementById('info')
    const offset = document.getElementById('offset')
    const client = document.getElementById('client')
    const showSvg = document.getElementById('showSvg')
    const svgElement = document.getElementById('svgElement')
  
    //  取得 viewport 座標系統中的 offset 和 client 座標值,並顯示於視窗上
    offset.textContent = `offset (${e.offsetX}, ${e.offsetY})`
    client.textContent = `client (${e.clientX}, ${e.clientY})`
  
    //  建立 SVG 座標點(0, 0)
    const clientPoint = svg.createSVGPoint()
    //  取得 CTM
    const CTM = svg.getScreenCTM()                      
    //  將 SVG 座標點的 x, y 設成 client(x, y)
    clientPoint.x = e.clientX                                   
    clientPoint.y = e.clientY
    //  將 client 的座標點轉成 SVG 座標點
    SVGPoint = clientPoint.matrixTransform(CTM.inverse())   
    //  將資訊顯示於視窗上
    showSvg.textContent = `svg (${SVGPoint.x.toFixed(0)}, ${SVGPoint.y.toFixed(0)})`
}
svg.addEventListener('mousemove', reportCurrentPoint)
  
在這裡我把最後顯示的 SVG 座標加上 SVGPoint.x.toFixed(0) 加上 toFixed(0) ,因為矩陣轉換之後得到的會是很多為的小數,不方便觀看,因此我就四捨五入把小數點去除。

改變 viewBox 看看座標系統會有什麼樣的影響


接著,你可以改變設定在 SVG 中的 viewBox 值,看看對於 SVG 的座標系統會有什麼樣的影響,在 viewBox 中前兩項(min-xmin-y)控制了位移;後兩項控制了縮放(widthheight),為了方便瞭解,會建議你在後兩項的設定和 viewport 是等比例的縮放,也就是我們原本的 viewport 是 600 x 300 ,你可以把後面這兩項等比例放大設為 1200 x 600 或縮小 300 x 150,來看效果。

例如,一開始 viewport 和 viewBox 的尺寸相等,且沒有位移時(viewBox = "0 0 600 300"),圓心的點座標會如下圖:


但是當我把 viewBox width 和 height 做等比例的改變後(viewBox = "0 0 450 225"),你會發現圓點的座標,在 SVG 座標系統仍然是 svg(300, 150),但是在 viewport 的座標系統中 offset(400, 200),client(409, 209)。這是因為當我的 viewBox 比例縮小為原本的 0.75 倍時,SVG 座標系統則會被放大(從左上角的地方放大),進而把圓點從中心的位置往右下推動,而圓也跟著放大了。

是從左上方當中心開始做縮放

你可以再把 viewBox 設定成不同的值測試看看,這將有助於你更能夠掌握 viewport 和 ViewBox 之間的關係。

進一步瞭解 CTM


在這裡我們會簡單說明 CTM 矩陣,如果你只是想要瞭解如何用 viewBox 做到縮放和拖移的效果,沒有瞭解 CTM 矩陣並不會有太大的影響。但是如何你想要對於座標點是如何在兩個不同的座標系統間轉換,瞭解一下矩陣的運算是很有幫助了。另外,在這裡我們是針對 SVG 這個 tag 本身去取得 CTM ,如果你是想要針對 SVG 裡的圖形(例如,<circle></circle>, <path></path>)去做變化,其背後的原理是相似的,但這就不在我們這篇討論的範圍內了。

CTM 概念說明

CTM 它的本質是一個 3 X 3 的矩陣,長的像這樣子:
CTM 矩陣的樣子
其中 e 和 f 控制的是位移(translate);a 和 d 控制的是縮放(scale)。

更深入的話,透過 a, b, c, d, e, f 的改變,我們就能達到各種縮放、位移、選轉、變形等等的效果。

在矩陣的概念中,我們則是將座標點用這樣的方式表示:
以矩陣的方式表示座標點

所以從 SVG 座標系統的座標點透過 CTM 轉到 viewport 座標系統的座標點,公式就像這樣:

SVG 座標系統的座標點透過 CTM 轉到 viewport 座標系統的座標點

如果想從 viewport 座標系統轉回 SVG 座標系統就需要利用到 CTM 的反矩陣(以-1表示),公式像這樣:

viewport 座標系統轉回 SVG 座標系統就需要利用到 CTM 的反矩陣(以-1表示)

讓我們來實做看看

這裡推薦一個很方便的網站-矩陣計算器,我們就用這個網站來驗證我們上面程式的結果。

現在我把上面範例中的 viewBox = "0 0 450 225" 的設定,透過上面所教得方式,我們將滑鼠游標移到圓心,可以發現在 viewport 座標系統中的 client(409, 209),接著,利用 svg.getScreenCTM() 我們可以取得當前 SVG 元素的 CTM ,算出來是的 matrix(a, b, c, d, e, f) = matrix(1.3333, 0, 0, 1.3333, 9, 9),現在我們就可以把這些值代入矩陣計算機中,按下等號:



計算的結果就像這樣,你可以看到最後的結果就是(300, 150),而這也就是我們上面利用程式所顯示的 SVG 座標值:

計算方式對應回我們的公式

重點總結


  • 利用魔術棒(CTM)可以讓 viewport 座標系統中的 client 座標值和 SVG 座標系統中的座標值做轉換。
  • 要記得轉換回去的是 viewport 的 clientXY 而不是 offsetXY

參考資料


2017年3月20日

[技術分享] 理解 SVG 中的 Viewport 和 ViewBox-拖曳與縮放功能實做(上)



不同於以往將 SVG 視為一張圖案(ICON 或 LOGO)的概念,在這篇文章中,我們要試著將 SVG 視為一個畫布(Canvas),而我們可以透過滑鼠來直接對這個畫布像 Google Map 一樣進行拖曳和縮放。

要將 SVG 視為一個畫布,並實做出縮放或拖曳的功能,有許多對於 SVG 的基本概念是我們需要先瞭解的,就讓我們一步一步來瞭解。


註1:在這篇文章中我們會把 SVG 視為一個畫布(Canvas),針對<svg></svg>而不是去探究 SVG 中各個元素(例如,<circle></circle>,<rect></rect>)。
註2:在這篇文章中我們只考慮 viewport 和 viewBox 為等比例的情況。


實做出來的效果會像這樣:

建議可以在 jsfiddle 中檢視,使用滑鼠縮放時比較不會拖曳到視窗。

瞭解 SVG 中的 Viewport 和 ViewBox


在 SVG 的世界中,空間的概念可以分成 viewportviewBox 兩個部分。在這篇文章中,我會把它 Viewport 比喻作相框,ViewBox 比喻作相片。

Viewport 相對上比較好理解,就是相框的大小,也就是你的眼睛看得到的範圍,不管你的相片多大,你能看到的實際範圍就是相框的大小。在網頁中我們可以透過設定 viewport 來調整我們相框的大小。

ViweBox 則可以想成是這張照片的大小,如果相片的大小和相框(viewport)一樣大的時候,自然不會有什麼問題,你可以從相框中看到完整的相片。可是,如果相片(viewbox)比起相框來得大或來得小時,這時候就會比較麻煩些,你會需要多去控制這張相片應該要如何的排置在相框上,才能夠呈現出你想要呈現的東西,因此 ViewBox 中除了能夠控制的相片的大小之外,還能夠控制相片要如何擺放在相框中

讓我們來瞭解一下 viewport 和 viewbox 的概念:

當 viewBox 等於 viewport 的情況(初始化的情況)

首先,我們可以直接在 SVG 元素上定義 viewport 的寬高,或者你也可以用 CSS 定義,基本上這個 DOM 元素的寬高就是 viewport 的寬高,也就是你相框的寬高。接著我們可以在 SVG 中定義我們的圖案內容,在這裡先用一隻鳥當作示範。

<svg id="svgElement" width="800" height="400">
    <g><!--  鳥的圖形 --></g>
</svg>

這時候便會得到如下的一個 viewport:


再來我們可以設定 viewBox , viewBox 的設定可以寫在 SVG 的標籤上,設定屬性包含 <min-x> <min-y> <width> <height> 這四個屬性,也就是 viewBox = '<min-x> <min-y> <width> <height>'

我們剛剛有提到,ViewBox 中除了能夠控制的相片的大小之外,還能夠控制相片要如何擺放在相框中,其中 <min-x> 和 <min-y> 就是在控制相片要如何擺放在相框中,而 <width> 和 <height> 則是相片的大小。

預設的情況下(沒有特別去設定 viewBox 的情況下)viewBox 的大小會和 viewport 一樣 ,因此當我們把鳥的相片放入 svg 後,他會自動填滿整個相框,兩個的大小會是一樣的,程式碼會像這樣:
   

<svg id="svgElement" width="800" height="400" viewBox="0 0 800 400">
    <g><!--  鳥的圖形 --></g>
</svg>

相片會很完整的融入在相框當中


當 viewbox 小於 viewport 時:圖案會被放大

透過 viewBox 的設定,我們可以進一步放大或縮小我們的相片,這裡有一個原則是,viewBox 會自動盡可能去填滿成 viewport 的大小,說起來很抽象,假設一開始我的 viewport 為 800 x 400 ,現在我們可以透過 viewBox 的設定,使得我們 viewBox 的大小變成 400 x 200,當我們的相片大小設定的比相框還小時,它會在原本的相片上裁切一小塊區域(在這裡是 400 x 200),接著把它調整到填滿整個 viewport 的大小(800 x 400)。

分解過程像是這樣(綠色是 viewport,藍色是 viewBox)
(1)設定比 viewport 還要小的 viewBox:
我們在 800 x 400 的 viewport 中設定了 400 x 200 的 viewBox 。

(2)裁切:
這時候因為我們設定的尺寸比相片原本的大小還要小,所以相片會被裁切。

(3)填滿 viewport
最後 viewBox 會盡可能的填滿整個 viewport,造成放大的效果。

這時候就會產生一個很神奇的現象,因為我的相片尺寸(viewBox)設定的比原本相片的尺寸來的小,它會先把相片裁切成我們指定的大小,但是因為它會自動去填滿整個 viewport 的緣故,所以造成了相片被放大的效果。

程式碼寫起來像是這樣,設定為 viewBox="0 0 400 200"
     

<svg id="svgElement" width="800" height="400" viewBox="0 0 400 200">
    <g><!--  鳥的圖形 --></g>
</svg>

當 viewbox 大於 viewport 時:圖案會被縮小

類似的道理,當我們設定的 viewBox 大於 viewPort 時,它會先把這張照片的底圖放大(先放大成 1600 x 800),但是圖案大小不變,然後在盡可能的塞入 viewport 當中(這裡是 800 x 400):

設定 viewBox 為 1600 x 800,比原本的 viewport 大,照片的底圖會被放大,但是圖案大小不會變。

把 viewBox (1600 x 800)塞進 viewport (800 x 400)當中,造成圖案變小的情況。

所以,雖然 viewBox 的設定(1600 x 800)大於原始 viewBox 的大小(800 x 400),但是實際上,照片在實際顯示上卻會被縮小。
程式碼寫起來會像這樣子,viewBox="0 0 1600 800"

<svg id="svgElement" width="800" height="400" viewBox="0 0 1600 800">
    <g><!--  鳥的圖形 --></g>
</svg>

SVG ViewBox 位置的設定

在 viewBox 的設定中,包含了四個屬性值 viewBox = '<min-x> <min-y> <width> <height>',除了上面我們所說的可以設定照片的大小外,也可以裡用 <min-x> 和 <min-y> 這兩個屬性來設定相片的位置。

例如說當我設定 viewBox 的 min-x 為 150 時( viewBox = "150 0 800 400" ),viewBox 會向左移動 150 單位 :

將 viewBox 設定為 "150 0 800 400" 時,viewBox 會向左移動 150 單位。

實際上可以看到的圖案範圍變小。

我們也可以同時設定 min-x 和 min-y ,例如(viewBox = "-400 -200 800 400"),會得到這樣的效果:

將 viewBox 設定為 "-400 -200 800 400"

我們最後實際上可以看到的圖案內容。

viewBox 設定造成的影響


在 viewBox 的設定的四個屬性值中 viewBox = "min-x min-y width height",我們可以簡單理解成前兩項 min-xmin-y 控制的是位移(translate),可以達到左右移動的效果;widthheight 控制的則是縮放(scale)。

但是因為 viewBox 實際上影響的是 SVG 當中的座標系統(後面會說明 SVG 座標系統),所以會和你的直覺有寫相反,例如,當你設定 min-xmin-y 越大時,實際上看到的畫面會往左上方移動;同樣地,當你設定widthheight 越大時,實際上看到的畫面會往縮小。

這個部分需要你花一些時間實際感受一下,非常建議利用下段中由 Sara Soueidan 所提供的實做案例感受。

實際感受 SVG 的效果


Sara Soueidan 在她所撰寫文章中 Understanding SVG Coordinate Systems and Transformations (Part 1) — The viewport, viewBox, and preserveAspectRatio 提供了非常好的實做案例

建議先把 viewBox 設定成和 viewport 大小一樣(800 x 600),這是一般初始化狀況。

左上角的地方你可以去以用拖拉的方式去設定 viewBox 的值,以此感受 viewBox 的改變對於視覺上顯示的效果,另外,由於它的 viewport 是 (800 x 600) 所以建議你可以先把 viewBox 的長寬設為 800 x 600(也就是初始化時預設 viewport = viewBox 的狀況),接著再來實際操作看看 viewBox 的改變會有什麼樣的效果。

當 viewport 和 viewBox 的尺寸並不是等比例時:preserveAspectRatio


我們提到,當 viewBox 和 viewport 的尺寸大小不一樣時, viewBox 會盡可能的去填滿整個 viewport ,可是在上面文章的例子中,我們都把 viewBox 的尺寸大小設定的和 viewPort 是等比例的情況,如果是不同比例的話,又要用什麼樣的方式來對齊和填滿呢?

這時候我們就會需要用到 preserveAspectRatio這個屬性了。在這個屬性當中可以設定對齊的方式(align)還有要用什麼樣的方式填滿(meetOrSlice)。

在這篇文章中,我只打算探討 viewBox 和 viewport 為等比例的情況,我認為要瞭解 viewport 和 viewBox 的概念時,先以等比例的情況當作實例來練習是比較容易瞭解的,因為在等比例的情況下,preserveAspectRation 的值對於畫面的呈現是沒有任何影響的,如果對於 preserveAspectRatio 的屬性想要有更多的瞭解,可以參考這篇文章([譯] 理解 SVG 座標系統與 Transformations @ Andyyou),或進一步參考文章中最後面所列的參考文章。

深入瞭解 SVG 座標系


從上面我們可以看出當我們為 SVG 元素設定 viewBox 時,SVG 會有很多特別的效果,我們可以視整個 SVG 元素為一個畫布,對它進行縮放和移動,而實際上在 SVG 的世界中,我們要瞭解viewport 和 viewBox 實際上是處在兩個不同的座標系統中。

在 viewport 中,是我們過去所熟悉的,以瀏覽器中 DOM 為主的座標系統,通常把它稱作 viewport 座標系統(viewport coordinate system)canvas 座標系統(但是不要把它和 HTML 中的 Canvas 標籤搞混)。一般來說,在這個座標系統中,1px 就是 1px 大,這個座標系統是相對固定的。在後面的文章中,我會使用 viewport 座標系統這個詞。

在 viewBox 的座標系統中,就不是我們所習慣的情況了,我們一般把在 viewBox 的座標系統稱作 SVG 座標系統(SVG coordinate system)用戶座標系統(user coordinate system)(或 the current coordinate systemuser space in use)。在後面的文章中,我會使用 SVG 座標系統這個詞。

在這個座標系統中,值是不一定要有單位的,如果我們沒有給它單位時,預設它會以 viewport 的單位為單位(例如,px),然而,恐怖的特別的地方在於,當我們在設定 viewBox 時,如果對於這當圖片有縮放時,那麼它的 1 單位大小將不會再是 1px 。

認識不同的座標名稱


為了幫助我們更進一步的探討 SVG 座標系統和 viewport 座標系統,我們要來認識三種不同的座標名稱,分別是 offsetclientSVG Point

其中 offset 和 client 所取得的值都是屬於 viewport 座標系統中的座標值(也就是 1px 就是真實的 1px),兩者的差別在於 offset 是相對於 container 左上角的點,也就是以 container 左上角為(0, 0);而 client 則是相對於 window 左上角(瀏覽器視窗左上角)的點,以 window 左上角為(0, 0),越右 X 值越大,越下 Y 值越大。

offset 的座標值以 container 左上角為準;client 的座標值以瀏覽器視窗(window)左上角為準。

SVG 座標系統就比較特別一點了,它有專屬的座標點,可以透過 viewport 座標系的 client 座標加以轉換。

我們會在下一篇文章中說明如何取得這三個座標值,但目前你需要知道有這三種不同的座標名稱。

viewBox 等同於 viewport 時

我們用 Sara Soueidan 在所提供的互動案例來做更多說明,一開始的時候,我先把 viewBox 設成和 viewport 一樣大,在這種情況下 SVG 座標系統中的 1 單位大小會和 viewport 的一樣大,也就是 1px。留意藍色的尺標是 SVG 座標系統、灰色的尺標則是 viewport 座標系統:

當 viewBox 等於 viewport 時,viewBox 就等於 viewport 的 1px,也就是實際 1px 大。

同時我們標下鳥右下角的這個點,讓我們後面能夠更清楚 SVG 座標系統的變化。在一開始的時候,因為 viewport 等同於 viewBox 的緣故,所以 SVG 座標系統中這個點,和 viewport 座標系統中的 offset 座標點,都會是(200, 300):


當 viewBox 為 viewport 尺寸的一半時

接著,我們可以看到,當我把 viewBox 設為 viewport 的一半(viewBox = "0 0 400 300"),也就是讓鳥看起來變 2 倍大時,這時候 SVG 座標系統中的 1 單位,會變成 viewport 的 2 單位(這裡就是 2px)。

當 viewBox 為 viewport 的一半時,圖案會被裁切後放大,所以 SVG 座標系統中的 1 單位,會變成 viewport 的 2 單位大(2px)。

但要留意的是,雖然圖案被放大了兩倍,但是鳥右下角這點在藍色尺標的 SVG 座標系統中一樣是(200, 300),可是灰色尺標的 viewport 座標系統中的 offset 座標點會變成兩倍,也就是(400, 600)。

當 viewBox 為 viewport 尺寸的兩倍大時

同樣的道理,當我把 viewBox 設為 viewport 的 2 倍大(viewBox = "0 0 1600 800"),也就是讓鳥看起來變 2 倍大時,這時候 SVG 座標系統中的 1 單位,會變成 viewport 的 1/2 單位(這裡就是 0.5px)。

這時候你會看到,雖然藍色尺標的 SVG 座標系統中,右下角座標仍然是(200, 300),但是 viewport 座標系統中的 offset 座標點則變成了(100, 150)。



重點總結


在這篇文章中有幾個你應該留意的重點:
  • 知道有 viewport 和 SVG 這兩種不同的座標系統
  • 知道有 offset, client, SVGPoint 這三種不同的座標點
  • 知道有 offset 和 client 屬於 viewport 座標系統;SVGPoint 屬於 SVG 座標系統
  • 知道透過 viewBox 的設定可以達到縮放和移動的效果

參考資料