Remotion LabRemotion Lab
返回模板庫

圖釘落點動畫

台灣輪廓地圖上,台北、台中、台南、高雄、花蓮五個城市圖釘依序從上方以 spring 彈入,落在正確的地理位置,落地後顯示地名,最後所有圖釘同時出現漣漪脈衝。

地圖圖釘彈簧動畫標記城市台灣
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import React from "react";

// 投影函數
const GEO = { lngMin: 119.5, lngMax: 122.5, latMin: 21.7, latMax: 25.5 };
const MAP = { x: 80, y: 60, w: 560, h: 855 };

const project = (lat: number, lng: number) => ({
  x: MAP.x + ((lng - GEO.lngMin) / (GEO.lngMax - GEO.lngMin)) * MAP.w,
  y: MAP.y + ((GEO.latMax - lat) / (GEO.latMax - GEO.latMin)) * MAP.h,
});

// 台灣輪廓座標 [lat, lng]
const TAIWAN-OUTLINE: [number, number][] = [
  [25.3, 121.54],
  [25.13, 121.74],
  [25.0, 122.0],
  [24.87, 121.83],
  [24.72, 121.77],
  [24.6, 121.87],
  [23.98, 121.61],
  [23.55, 121.55],
  [23.09, 121.37],
  [22.75, 121.1],
  [22.3, 120.9],
  [21.9, 120.85],
  [22.17, 120.7],
  [22.38, 120.49],
  [22.62, 120.26],
  [23.05, 120.1],
  [23.42, 120.12],
  [23.71, 120.29],
  [23.96, 120.43],
  [24.2, 120.57],
  [24.56, 120.78],
  [24.85, 120.88],
  [25.0, 121.35],
  [25.17, 121.43],
  [25.3, 121.54],
];

const taiwanPath = TAIWAN-OUTLINE.map(([lat, lng], i) => {
  const p = project(lat, lng);
  return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`;
}).join(" ") + " Z";

// 5 個城市圖釘(台北、台中、台南、高雄、花蓮)
const PINS = [
  { name: "台北", lat: 25.033, lng: 121.565, delay: 0 },
  { name: "台中", lat: 24.148, lng: 120.674, delay: 18 },
  { name: "台南", lat: 23.0, lng: 120.227, delay: 36 },
  { name: "高雄", lat: 22.627, lng: 120.301, delay: 54 },
  { name: "花蓮", lat: 23.991, lng: 121.611, delay: 72 },
].map((p) => ({ ...p, ...project(p.lat, p.lng) }));

// SVG 淚滴形狀
const PIN-PATH =
  "M 0 -28 C -14 -28, -22 -18, -22 -7 C -22 7, 0 28, 0 28 C 0 28, 22 7, 22 -7 C 22 -18, 14 -28, 0 -28 Z";

export const MapPinDrop: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 全部落地後脈衝(frame > 110)
  const allLanded = frame > 110;
  const pulseRipple = allLanded
    ? interpolate(frame - 110, [0, 40], [0, 1], {
        extrapolateLeft: "clamp",
        extrapolateRight: "clamp",
      })
    : 0;
  const rippleScale = interpolate(pulseRipple, [0, 1], [1, 3.5]);
  const rippleOpacity = interpolate(pulseRipple, [0, 0.6, 1], [0.7, 0.3, 0]);

  return (
    <AbsoluteFill
      style={{
        background: "#050d1a",
        fontFamily: "sans-serif",
      }}
    >
      <svg
        width="1920"
        height="1080"
        viewBox="0 0 1920 1080"
        style={{ position: "absolute", top: 0, left: 0 }}
      >
        <defs>
          <pattern id="mapGrid" width="80" height="80" patternUnits="userSpaceOnUse">
            <rect width="80" height="80" fill="none" stroke="#0e1e30" strokeWidth="1" />
          </pattern>
          <filter id="pinGlow">
            <feGaussianBlur stdDeviation="3" result="blur" />
            <feMerge>
              <feMergeNode in="blur" />
              <feMergeNode in="SourceGraphic" />
            </feMerge>
          </filter>
        </defs>

        {/* 背景格線 */}
        <rect width="1920" height="1080" fill="url(#mapGrid)" />

        {/* 台灣輪廓 */}
        <path
          d={taiwanPath}
          fill="#0f2040"
          stroke="#1e3a5f"
          strokeWidth="2"
          strokeLinejoin="round"
        />

        {/* 圖釘 */}
        {PINS.map((pin, i) => {
          const sp = spring({
            frame: frame - pin.delay,
            fps,
            config: { damping: 14, stiffness: 200, mass: 0.8 },
          });

          const dropY = interpolate(sp, [0, 1], [-140, 0], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          });

          const scaleY = interpolate(sp, [0, 0.7, 1, 1.2], [0.3, 1.2, 1.0, 0.9], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          });
          const scaleX = interpolate(sp, [0, 0.7, 1, 1.2], [0.3, 0.85, 1.0, 1.1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          });

          const textOpacity = interpolate(frame - pin.delay - 10, [0, 20], [0, 1], {
            extrapolateLeft: "clamp",
            extrapolateRight: "clamp",
          });

          const landed = frame > pin.delay + 15;

          return (
            <g key={pin.name} transform={`translate(${pin.x}, ${pin.y})`}>
              {/* 脈衝漣漪 */}
              {allLanded && (
                <circle
                  cx="0"
                  cy="4"
                  r={22 * rippleScale}
                  fill="none"
                  stroke="#ef4444"
                  strokeWidth="2"
                  opacity={rippleOpacity}
                />
              )}

              {/* 落地陰影 */}
              {landed && (
                <ellipse
                  cx="0"
                  cy="30"
                  rx={12 * scaleX}
                  ry={4}
                  fill="#000000"
                  opacity="0.4"
                />
              )}

              {/* 圖釘本體 */}
              <g transform={`translate(0, ${dropY}) scale(${scaleX}, ${scaleY})`}>
                <path
                  d={PIN-PATH}
                  fill="#ef4444"
                  stroke="#991b1b"
                  strokeWidth="2"
                  filter="url(#pinGlow)"
                />
                <circle cx="0" cy="-7" r="7" fill="#fca5a5" opacity="0.8" />
              </g>

              {/* 地名標籤 */}
              <text
                x="30"
                y="5"
                fill="#f1f5f9"
                fontSize="20"
                fontWeight="600"
                opacity={textOpacity}
              >
                {pin.name}
              </text>
            </g>
          );
        })}
      </svg>

      {/* 右側資訊面板 */}
      <div
        style={{
          position: "absolute",
          right: 80,
          top: "50%",
          transform: "translateY(-50%)",
          width: 300,
          background: "rgba(5, 13, 26, 0.92)",
          border: "1px solid #1e3a5f",
          borderRadius: 14,
          padding: "28px 32px",
          opacity: interpolate(frame, [20, 50], [0, 1], { extrapolateRight: "clamp" }),
        }}
      >
        <div
          style={{
            color: "#3b82f6",
            fontSize: 13,
            letterSpacing: 3,
            marginBottom: 6,
            textTransform: "uppercase",
          }}
        >
          台灣城市
        </div>
        <div
          style={{
            color: "#f1f5f9",
            fontSize: 26,
            fontWeight: 700,
            letterSpacing: 2,
            marginBottom: 24,
            borderBottom: "1px solid #1e3a5f",
            paddingBottom: 16,
          }}
        >
          標記地點
        </div>
        {PINS.map((pin) => (
          <div
            key={pin.name}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 14,
              marginBottom: 16,
              opacity: interpolate(frame - pin.delay - 10, [0, 20], [0, 1], {
                extrapolateLeft: "clamp",
                extrapolateRight: "clamp",
              }),
            }}
          >
            <div
              style={{
                width: 10,
                height: 10,
                borderRadius: "50%",
                background: "#ef4444",
                boxShadow: "0 0 6px #ef4444",
                flexShrink: 0,
              }}
            />
            <span style={{ color: "#e2e8f0", fontSize: 20 }}>{pin.name}</span>
          </div>
        ))}
      </div>

      {/* 標題 */}
      <div
        style={{
          position: "absolute",
          top: 48,
          left: 0,
          right: 0,
          textAlign: "center",
          color: "#f1f5f9",
          fontSize: 40,
          fontWeight: 700,
          letterSpacing: 6,
          opacity: interpolate(frame, [0, 20], [0, 1], { extrapolateRight: "clamp" }),
        }}
      >
        城市圖釘落點
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼