之前接到製作進度地圖的任務,地圖上有個不規則的曲線,以及代表步驟的數個點。我不疑有他,匯出一張帶有曲線的圖片當底圖,再直白地用 position、top 與 left 把一個個地點定位好。做完了!讚!
正當我帶著保溫杯,出了辦公室,喝著咖啡還唱著歌,突然就被麻匪劫了!
越想越不對勁,這個地圖是動態的。在設計稿的其他頁面中有 UI 能增減步驟數量,而這邊的設計稿畫的是最多地點時的狀況。然後還有一條會沿著路徑,畫到完成步驟的虛線。這下好了,步驟定位還可以靠 CSS 硬幹,但是動態增減的路徑,還真的要靠操作 SVG 來解決。
製作路線圖會用到「SVG 座標」與「SVG DOM API」兩個知識點,寫起來有點多。今天先了解什麼是 SVG 的座標吧!
<path> 中的 d 是什麼?d 是用來定義這個路徑要怎麼畫,前幾天作為範例的星星中,就能看到 d 是一長串的英文與數字,這些其實就是一個個的繪圖指令與座標。常見的繪圖指令有:
M:Move to,移動到指定座標,提筆不畫線。L:Line to,畫直線到指定座標。H:Horizontal Line to,畫水平直線到指定的 x 座標。V:Vertical Line to,畫垂直直線到指定的 y 座標。C / S:Cubic Bézier Curve / Smooth Cubic Bézier Curve,畫三次貝茲曲線。Q / T:Quadratic Bézier Curve / Smooth Quadratic Bézier Curve,畫二次貝茲曲線。A:Arc,畫橢圓弧。Z:Close Path,封閉路徑,將終點與起點連起來。由此可知,path 是由一連串的筆畫組成。所以反過來想,我們也能一段段地把它拆解獨立出來。
補充 - 貝茲曲線
以
C為例,類似使用設計軟體畫曲線的操作,決定起點與終點,再拉控制點調整曲線弧度。<path d="M40 220 C80 30 320 30 360 220" ... /> └─起點─┘ └控制1┘└控制2┘└─終點─┘
d 裡的座標並不是 px,而是 SVG 自己的座標系。以 <svg viewBox="0 0 900 600"> 為例,viewBox 的
四個值分別是 min-x min-y width height,意思是「這張畫布的座標,從 (0, 0) 到 (900, 600)」。
無論寬高如何變化,SVG 內部的圖形都是參考畫布設定,等比例縮放。也就是說我們可以取 <path> 的繪圖座標,推算步驟圓點的定位。
上面的公式有個前提:<svg> 元素的長寬比,要和 viewBox 一樣。如果不一樣,例如把 900 × 600 的 viewBox 塞進一個正方形,SVG 預設(preserveAspectRatio="xMidYMid meet")會像 object-fit: contain 一樣,等比縮放並置中,上下多出留白。這時用百分比算出來的位置就會偏掉。
最簡單的解法,是讓容器比例和 viewBox 一致:在 CSS 下aspect-ratio: 900 / 600 指定長寬比,這樣就沒有留白。
先從設計稿匯出 SVG。下面這個範例是一條的半透明路線。把路徑 d 與 viewBox 設定成變數,方便等等使用。
<script setup>
const ROADMAP_PATH_D = 'M109.5 78.7C147.1 84.3 161.5 156.3 171.1 268.3C180.7 380.3 195.1 434.7 243.1 439.5C275.1 442.7 291.1 448.3 319.1 412.3C363.1 356.3 427.1 244.3 486.3 213.9C515.1 199.5 535.1 196.3 547.1 224.3C559.1 252.3 561.5 324.3 564.7 396.3C567.9 468.3 571.1 516.3 619.1 522.7C667.1 529.1 699.1 476.3 801.5 360.3';
const VIEWBOX = { width: 900, height: 600 };
</script>
<template>
<svg viewBox="0 0 900 600">
<path
:d="ROADMAP_PATH_D"
fill="none"
stroke="#8b0000"
stroke-width="40"
stroke-linecap="round"
opacity="0.4"
/>
</svg>
</template>

關卡節點是 HTML 元素,要疊在 SVG 上面,所以需要一個容器當定位基準。
<template>
<div class="roadmap">
<svg viewBox="0 0 900 600">
<path ... />
</svg>
</div>
</template>
<style scoped>
.roadmap {
position: relative; /* 讓子元素以它為定位基準 */
width: 100%;
max-width: 900px;
aspect-ratio: 900 / 600; /* 與 viewBox 同比例 */
}
</style>
利用 aspect-ratio: 900 / 600 讓容器、SVG、viewBox 三者比例一致,這樣 SVG 裡就不會出現 preserveAspectRatio 造成的留白。
從 d 裡挑出每段曲線的終點。我們先用 <circle> 在 SVG 裡畫出來,確認座標真的落在路線上。
<script setup>
// 從 d 裡挑出的曲線段終點,當作關卡座標(viewBox 座標)
const LEVEL_POINTS = [
{ x: 109.5, y: 78.7 },
{ x: 243.1, y: 439.5 },
{ x: 486.3, y: 213.9 },
{ x: 564.7, y: 396.3 },
{ x: 619.1, y: 522.7 },
{ x: 801.5, y: 360.3 },
]
</script>
<template>
<div class="roadmap">
<svg class="roadmap__svg" viewBox="0 0 900 600">
<path ... />
<!-- 驗證用,確認後刪除 -->
<circle
v-for="(point, index) in LEVEL_POINTS"
:key="index"
:cx="point.x"
:cy="point.y"
r="10"
fill="blue"
/>
</svg>
</div>
</template>
就能看到 6 個藍點,全都落在路線上。
<circle> 能做的樣式有限。如果節點要放文字、圖片、hover 效果,用 HTML 會方便得多,所以接下來要把座標換算給 HTML 用。先刪掉驗證用的 <circle>,改用 HTML 的 <div>:
<script setup>
import { computed } from 'vue'
// ...
const nodes = computed(() =>
LEVEL_POINTS.map(({ x, y }) => ({
style: {
left: `${(x / VIEWBOX.width) * 100}%`,
top: `${(y / VIEWBOX.height) * 100}%`,
},
}))
)
</script>
<template>
<div class="roadmap">
<svg ...>...</svg>
<div
v-for="(node, index) in nodes"
:key="index"
class="roadmap__node"
:style="node.style"
>
{{ index + 1 }}
</div>
</div>
</template>
<style scoped>
/* ... */
.roadmap__node {
position: absolute;
width: 40px;
line-height: 40px;
text-align: center;
border-radius: 50%;
color: #fff;
background: #bbb;
transform: translate(-50%, -50%); /* 讓節點中心對齊座標點 */
}
</style>
node 定位計算方式:
單位之所以用百分比而不是 px,是為了讓容器縮放時節點會自動跟著移動。以第一個點為例:109.5 / 900 ≈ 12%、78.7 / 600 ≈ 13%。
.
.
.
這樣寫比原本用 position 定位還要準確,定位的點一定會在路徑上。不過目前座標是手動設定,還沒處理步驟數量變動,以及那條要畫到目前進度的虛線。
明天,我們會用 SVG DOM API 測量 path,再用 JavaScript 將圖形資訊換算成變動的定位點和進度線結束處。