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

參考資料


0 意見:

張貼留言