折線圖:stroke-dasharray 揭示動畫
用 stroke-dasharray 讓 SVG 折線從左邊「畫」到右邊。學會把任意 path 變成可動畫的線條、加上 area-fill 漸層、每個資料點的小圓點依序冒出來——這是趨勢類視覺最好用的招式。
成品預覽
6 秒、一週的 DAU(每日活躍用戶)折線圖:32 → 41 → 56 → 48 → 67 → 79 → 92。藍色折線從左到右「畫」出來、底下的漸層 area 同步揭示、每個資料點的小圓點跟著線一起冒出來、上方數字也同步顯示。整個動畫的核心是一個 SVG 老技巧——stroke-dasharray。
這篇教什麼
折線圖是「趨勢」視覺的標配。但要做出「線條被一筆畫出來」的效果,用 transform 是不行的——scaleX 會把線拉變形、width 對 path 沒用。正確的解法是 SVG 的 stroke-dasharray + stroke-dashoffset,這是個 20 年前就存在的老招式,但對 Remotion 來說剛好完美。
這篇你會學到:
- 怎麼把資料點轉成 SVG path 字串(
M+L命令) - 計算 path 的總長度(用 Euclidean 距離,不用
getTotalLength) stroke-dasharray+stroke-dashoffset揭示線條- per-point dot reveal(每個圓點按 t 順序冒出來)
- area fill 漸層(折線下面的「填色區域」)
懂這幾招你可以做折線圖、面積圖、雷達圖、心跳圖、股價圖——所有「線條跟資料點」的視覺都共用這套基礎。
前置知識
- T9:圓餅圖 SVG arc — 上一篇有 SVG path 的基本語法
- T24:SVG 簽名動畫 — 那篇有更深入的 stroke-dasharray 應用,本篇用最簡單的版本
Step 1:資料 + 座標映射
const LINE_DATA = [
{day: "Mon", value: 32},
{day: "Tue", value: 41},
{day: "Wed", value: 56},
{day: "Thu", value: 48},
{day: "Fri", value: 67},
{day: "Sat", value: 79},
{day: "Sun", value: 92},
];把資料點轉成 SVG 座標:
const CHART_X = 240;
const CHART_Y = 240;
const CHART_W = 1440;
const CHART_H = 540;
const maxValue = Math.max(...LINE_DATA.map((d) => d.value));
const minValue = 0;
const points = LINE_DATA.map((d, i) => ({
x: CHART_X + (i / (LINE_DATA.length - 1)) * CHART_W,
y: CHART_Y + CHART_H - ((d.value - minValue) / (maxValue - minValue)) * CHART_H,
value: d.value,
day: d.day,
}));幾個值得記下來的細節:
i / (length - 1)而不是i / length:這樣第一個點在 X=0、最後一個點在 X=1(占滿圖表寬度)。如果用i / length,最後一個點會停在 6/7 = 0.857 的位置,右邊留下一截空白- Y 軸要倒過來:SVG 的 y 軸向下,但我們要「值越大畫面越上面」,所以
CHART_Y + CHART_H - ratio * CHART_H maxValue / minValuenormalize:所有值都映射到 [0, CHART_H] 範圍。如果你的資料最小值不是 0(比如股價只在 100~200 區間),改成minValue = Math.min(...)會讓波動更明顯
Step 2:組 path 字串 + 算長度
const pathD = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ");
// → "M 240 480 L 480 410 L 720 290 L 960 350 ..."M 是「移到」、L 是「畫直線到」。第一個點用 M、其他都用 L,串起來就是一條 polyline。
接下來是這篇的精華——算 path 的總長度。為什麼要算?因為 stroke-dasharray 的揭示原理是「把虛線間隔設成跟線一樣長,然後 offset 從「整條線長」逐漸減到 0」。我們需要那個「整條線長」。
let totalLen = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x;
const dy = points[i].y - points[i - 1].y;
totalLen += Math.sqrt(dx * dx + dy * dy);
}對每兩個相鄰點算 Euclidean 距離(畢氏定理),加總。為什麼不用 getTotalLength()? 那是 DOM API(SVGPathElement.getTotalLength()),需要把 path 真的塞到 DOM 裡讓瀏覽器算。Remotion 渲染時 DOM 是 jsdom 而不是真瀏覽器,這個 API 不一定可靠。手刻 Euclidean 加總對「直線連接的 polyline」完全正確、純 JS、跑得飛快。
只有當你的 path 包含曲線(C / Q / A 命令)時才需要 @remotion/paths 的 getLength,因為曲線長度不是 Euclidean 算得出來的。本篇是純直線連接,所以不需要。
Step 3:stroke-dasharray 揭示
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
const LineChart: React.FC = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
// ...省略 points / pathD / totalLen 的計算...
const drawProgress = spring({
frame: frame - 20,
fps,
config: {damping: 30, stiffness: 60},
durationInFrames: 80,
});
return (
<AbsoluteFill style={{backgroundColor: "#0f172a"}}>
<svg width="1920" height="1080" viewBox="0 0 1920 1080">
<path
d={pathD}
fill="none"
stroke="#60a5fa"
strokeWidth={6}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={totalLen}
strokeDashoffset={totalLen * (1 - drawProgress)}
/>
</svg>
</AbsoluteFill>
);
};核心兩行:
strokeDasharray={totalLen}
strokeDashoffset={totalLen * (1 - drawProgress)}strokeDasharray={totalLen}:把 path 變成一條「破折線」,但破折段的長度等於整條線——所以視覺上看起來還是一條完整的線。
strokeDashoffset={totalLen * (1 - drawProgress)}:把這條「破折線」往前推 totalLen 個單位,整條線就被推到 path 之外了,看起來像「線不見了」。當 drawProgress 從 0 走到 1,offset 從 totalLen 走到 0,線就「從左邊冒出來、慢慢補滿」。
這招的優雅在於——任何 path 都適用。直線、曲線、L 型、Z 字型,只要算得出總長度,stroke-dasharray + offset 就能做出「畫筆從頭走到尾」的效果。
Step 4:Per-point 圓點冒出來
當折線畫到每個資料點時,要在那個位置「彈」出一個小圓點:
{points.map((p, i) => {
// 這個點在整條線的哪個 t 位置?
const t = i / (points.length - 1);
// 折線的 drawProgress 還沒走到這個點,dot 不顯示
if (drawProgress < t) return null;
// 已經過了這個點,dot 用 spring 彈出來
const dotProgress = spring({
frame: frame - 20 - t * 80, // t * 80 ≈ drawProgress 走到這個點的 frame
fps,
config: {damping: 14, stiffness: 200},
durationInFrames: 12,
});
return (
<g key={p.day}>
<circle
cx={p.x}
cy={p.y}
r={10 * dotProgress}
fill="#facc15"
/>
<text
x={p.x}
y={p.y - 24}
fill="#fff"
fontSize={28}
fontWeight={700}
textAnchor="middle"
opacity={dotProgress}
style={{fontVariantNumeric: "tabular-nums"}}
>
{p.value}
</text>
</g>
);
})}關鍵邏輯:
if (drawProgress < t) return null:如果線還沒畫到這個位置,直接不渲染這個 dot——這樣 dot 永遠不會「跑到線前面」r={10 * dotProgress}:dot 的半徑從 0 spring 到 10,視覺上是「彈出來」- 每個 dot 的 spring 起始 frame 是
frame - 20 - t * 80:t 位置 0 的 dot 從 frame 20 開始彈、t 位置 1 的 dot 從 frame 100 開始彈,剛好對齊 drawProgress 走到該點的時間
Step 5:Area fill 漸層
折線下面通常會加一塊「半透明填色」,讓圖表更有重量感:
<defs>
<linearGradient id="areaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity={0.5} />
<stop offset="100%" stopColor="#60a5fa" stopOpacity={0} />
</linearGradient>
</defs>
{/* area path = 折線 + 右下角 + 左下角 + 閉合 */}
<path
d={`${pathD} L ${points[points.length - 1].x} ${CHART_Y + CHART_H} L ${points[0].x} ${CHART_Y + CHART_H} Z`}
fill="url(#areaGradient)"
opacity={drawProgress}
/>幾個細節:
<linearGradient>從上到下 (x1="0" y1="0" x2="0" y2="1"):折線附近濃、底部透明- area path 是折線後面接「右下角 → 左下角 → 閉合」:把開放的折線變成一個封閉多邊形
opacity={drawProgress}:area 跟著線一起淡入(沒有自己的 stroke-dasharray,因為 fill 區域不能用那招)
如果你想要 area 也跟著「從左到右揭示」而不是整片淡入,需要用 <clipPath> 加一個動態長方形蓋住右半邊。本篇為了簡單就用 opacity 同步揭示,視覺上夠用了。
Step 6:渲染
npx remotion render AnimatedLineChart out/line-chart.mp4 --codec=h264常見問題
Q:line 在第一個資料點和最後一個資料點之間是「直直」連起來的,可以做平滑曲線嗎?
可以。把 L(直線)換成 C(cubic Bezier)或 Q(quadratic Bezier)就會變成平滑曲線。但這會有兩個問題:
- 算 path 長度變難——曲線長度不能用 Euclidean 算,要用
@remotion/paths的getLength - 控制點怎麼選——「平滑連接相鄰兩點」需要算 tangent 向量,這是 D3 的
d3.line().curve(d3.curveCatmullRom)在做的事
簡單版本:用 D3 的 d3.line().curve(d3.curveMonotoneX) 直接生 path 字串,再用 getLength 算長度。本篇為了不引入 D3 用了 polyline,視覺上 7 個點看起來已經夠平滑。
Q:drawProgress 走完之後 dot 還在彈,可以讓它停?
spring() 在 durationInFrames 之後會 clamp 在 1,dot 會穩定停在 r=10。如果你看到「無限彈跳」是 spring 的 damping 太低——把 damping 從 14 拉到 20 就會明顯穩。
Q:可以做多條線同時顯示嗎(比如 iOS vs Android DAU)?
可以,把 LINE_DATA 變成二維陣列、map 出多個 <path>。每條線可以用不同 stagger 揭示(比如 iOS 從 frame 20 開始、Android 從 frame 30 開始),畫面看起來像「藍線先畫、綠線跟進」。記得每條線要算自己的 totalLen。
Q:Y 軸刻度怎麼加?
手動寫一個 <g> 包幾個 <line> 跟 <text>:
{[0, 25, 50, 75, 100].map((v) => {
const y = CHART_Y + CHART_H - (v / 100) * CHART_H;
return (
<g key={v}>
<line x1={CHART_X} y1={y} x2={CHART_X + CHART_W} y2={y}
stroke="rgba(148,163,184,0.15)" strokeWidth={1} />
<text x={CHART_X - 16} y={y + 8} fill="#94a3b8" fontSize={20} textAnchor="end">
{v}
</text>
</g>
);
})}水平的細灰線 + 右對齊的數字 = 一個基本的 Y 軸網格。X 軸的天名(Mon/Tue/...)也是同樣做法。
Q:可以讓線「沿著畫到一半時停住」做為點暫停效果嗎?
可以。把 drawProgress 用 interpolate 控制 frame:
const drawProgress = interpolate(frame, [20, 60, 100, 140], [0, 0.5, 0.5, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});frame 60~100 之間 drawProgress 卡在 0.5,線會停在中間。可以在這 40 frame 內做別的事(顯示 callout 文字、放大數字標籤),然後再繼續畫到 1.0。這是「資料故事敘述」的常用節奏。
本篇涵蓋的官方文件
- /docs/animation-math — interpolate / spring 與時間映射
- /docs/spring — spring 物理動畫
下一步
T14:YouTube 縮圖批次產生器 — 學完四種圖表動畫之後,下一階段進入「媒體整合」主題:怎麼用 Remotion 不只是做影片,還能批次產出靜態圖(YouTube 縮圖、Open Graph 圖、社群貼文圖)。
或者直接跳到 D3 系列——
- D3 長條圖賽跑 — 把長條圖加上「動態重新排序」的元素
- D3 力導向圖 — 節點與連線的視覺
- D3 Sankey 流向圖 — 多階段流量視覺
四篇核心動畫篇 + D3 系列就是 Remotion 資料視覺化的完整工具箱。