Remotion LabRemotion Lab
核心動畫折線圖:stroke-dasharray 揭示動畫
data-vizline-chartsvgstroke-dasharrayintermediate

折線圖: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 來說剛好完美。

這篇你會學到:

  1. 怎麼把資料點轉成 SVG path 字串M + L 命令)
  2. 計算 path 的總長度(用 Euclidean 距離,不用 getTotalLength
  3. stroke-dasharray + stroke-dashoffset 揭示線條
  4. per-point dot reveal(每個圓點按 t 順序冒出來)
  5. area fill 漸層(折線下面的「填色區域」)

懂這幾招你可以做折線圖、面積圖、雷達圖、心跳圖、股價圖——所有「線條跟資料點」的視覺都共用這套基礎。


前置知識


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 / minValue normalize:所有值都映射到 [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/pathsgetLength,因為曲線長度不是 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)就會變成平滑曲線。但這會有兩個問題:

  1. 算 path 長度變難——曲線長度不能用 Euclidean 算,要用 @remotion/pathsgetLength
  2. 控制點怎麼選——「平滑連接相鄰兩點」需要算 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。這是「資料故事敘述」的常用節奏。


本篇涵蓋的官方文件


下一步

T14:YouTube 縮圖批次產生器 — 學完四種圖表動畫之後,下一階段進入「媒體整合」主題:怎麼用 Remotion 不只是做影片,還能批次產出靜態圖(YouTube 縮圖、Open Graph 圖、社群貼文圖)。

或者直接跳到 D3 系列——

四篇核心動畫篇 + D3 系列就是 Remotion 資料視覺化的完整工具箱。