Remotion LabRemotion Lab
返回模板庫

路線進度條

台灣地圖上顯示高鐵路線(台北→板橋→桃園→新竹→苗栗→台中→彰化→雲林→嘉義→台南→高雄),橘色移動光點表示列車位置,右側垂直進度條顯示四個主要站點(台北、台中、台南、高雄)的到達狀態。

地圖路線進度條站點高鐵台灣
提示詞(可直接修改內容)
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: 120, y: 80, w: 480, h: 730 };

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,
});

// 台灣輪廓
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";

// 高鐵站點(完整版,含地理座標)
const HSR-STATIONS = [
  { name: "台北", lat: 25.033, lng: 121.565, major: true },
  { name: "板橋", lat: 25.014, lng: 121.464, major: false },
  { name: "桃園", lat: 24.994, lng: 121.301, major: false },
  { name: "新竹", lat: 24.807, lng: 120.969, major: false },
  { name: "苗栗", lat: 24.56, lng: 120.82, major: false },
  { name: "台中", lat: 24.148, lng: 120.674, major: true },
  { name: "彰化", lat: 24.052, lng: 120.516, major: false },
  { name: "雲林", lat: 23.709, lng: 120.431, major: false },
  { name: "嘉義", lat: 23.48, lng: 120.449, major: false },
  { name: "台南", lat: 23.0, lng: 120.227, major: true },
  { name: "高雄", lat: 22.627, lng: 120.301, major: true },
].map((s) => ({ ...s, ...project(s.lat, s.lng) }));

// 高鐵路線 polyline 點
const hsrPoints = HSR-STATIONS.map((s) => `${s.x.toFixed(1)},${s.y.toFixed(1)}`).join(" ");

// 高鐵路徑總長度
const HSR-PATH-LENGTH = HSR-STATIONS.reduce((total, s, i) => {
  if (i === 0) return total;
  const prev = HSR-STATIONS[i - 1];
  const dx = s.x - prev.x;
  const dy = s.y - prev.y;
  return total + Math.sqrt(dx * dx + dy * dy);
}, 0);

// 各站點累積距離比例
const stationRatios = HSR-STATIONS.map((s, i) => {
  if (i === 0) return 0;
  let dist = 0;
  for (let j = 1; j <= i; j++) {
    const dx = HSR-STATIONS[j].x - HSR-STATIONS[j - 1].x;
    const dy = HSR-STATIONS[j].y - HSR-STATIONS[j - 1].y;
    dist += Math.sqrt(dx * dx + dy * dy);
  }
  return dist / HSR-PATH-LENGTH;
});

// 右側進度條的 4 個主要站
const MAJOR-STATIONS = HSR-STATIONS.filter((s) => s.major);

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

  // 列車行進進度(20 → 155 幀)
  const trainProgress = interpolate(frame, [20, 155], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  // 列車當前位置插值
  let trainX = HSR-STATIONS[0].x;
  let trainY = HSR-STATIONS[0].y;
  for (let i = 1; i < HSR-STATIONS.length; i++) {
    const segStart = stationRatios[i - 1];
    const segEnd = stationRatios[i];
    if (trainProgress <= segEnd) {
      const t = (trainProgress - segStart) / Math.max(segEnd - segStart, 0.001);
      trainX = HSR-STATIONS[i - 1].x + t * (HSR-STATIONS[i].x - HSR-STATIONS[i - 1].x);
      trainY = HSR-STATIONS[i - 1].y + t * (HSR-STATIONS[i].y - HSR-STATIONS[i - 1].y);
      break;
    }
    if (i === HSR-STATIONS.length - 1) {
      trainX = HSR-STATIONS[i].x;
      trainY = HSR-STATIONS[i].y;
    }
  }

  // 已完成路線長度(用於 dashoffset)
  const completedLength = trainProgress * HSR-PATH-LENGTH;

  // 閃爍
  const blink = Math.sin(frame * 0.35) * 0.5 + 0.5;

  // 標題淡入
  const titleOpacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: "clamp" });

  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="mrpGrid" width="60" height="60" patternUnits="userSpaceOnUse">
            <path d="M 60 0 L 0 0 0 60" fill="none" stroke="#0a1828" strokeWidth="1" />
          </pattern>
          <linearGradient id="hsrGrad" x1="0%" y1="0%" x2="0%" y2="100%">
            <stop offset="0%" stopColor="#f97316" />
            <stop offset="100%" stopColor="#ea580c" />
          </linearGradient>
        </defs>

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

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

        {/* 高鐵路線底層 */}
        <polyline
          points={hsrPoints}
          fill="none"
          stroke="#1e3a5f"
          strokeWidth="5"
          strokeLinecap="round"
          strokeLinejoin="round"
        />

        {/* 已行駛路線(橘色) */}
        <polyline
          points={hsrPoints}
          fill="none"
          stroke="url(#hsrGrad)"
          strokeWidth="5"
          strokeDasharray={`${HSR-PATH-LENGTH}`}
          strokeDashoffset={HSR-PATH-LENGTH - completedLength}
          strokeLinecap="round"
          strokeLinejoin="round"
          style={{ filter: "drop-shadow(0 0 6px rgba(249,115,22,0.7))" }}
        />

        {/* 各站點 */}
        {HSR-STATIONS.map((station, i) => {
          const reached = trainProgress >= stationRatios[i] - 0.01;
          const r = station.major ? 9 : 5;
          return (
            <circle
              key={station.name}
              cx={station.x}
              cy={station.y}
              r={r}
              fill={reached ? (station.major ? "#f97316" : "#fb923c") : "#1e3a5f"}
              stroke={reached ? "#fff" : "#0e1e30"}
              strokeWidth={station.major ? 2.5 : 1.5}
              style={reached && station.major ? { filter: "drop-shadow(0 0 5px #f97316)" } : {}}
            />
          );
        })}

        {/* 列車光點 */}
        {frame > 20 && (
          <g transform={`translate(${trainX}, ${trainY})`}>
            <circle
              r={16 + blink * 8}
              fill="none"
              stroke="#fbbf24"
              strokeWidth="2"
              opacity={0.3 + blink * 0.3}
            />
            <circle r={12} fill="none" stroke="#fbbf24" strokeWidth="2" opacity={0.6} />
            <circle
              r={6}
              fill="#fbbf24"
              style={{ filter: "drop-shadow(0 0 8px #fbbf24)" }}
            />
          </g>
        )}
      </svg>

      {/* 標題 */}
      <div
        style={{
          position: "absolute",
          top: 48,
          left: 660,
          right: 0,
          opacity: titleOpacity,
        }}
      >
        <div
          style={{
            color: "#f97316",
            fontSize: 18,
            letterSpacing: 4,
            marginBottom: 6,
          }}
        >
          高速鐵路
        </div>
        <div
          style={{
            color: "#f1f5f9",
            fontSize: 48,
            fontWeight: 800,
            letterSpacing: 4,
          }}
        >
          路線進度
        </div>
      </div>

      {/* 右側進度面板 */}
      <div
        style={{
          position: "absolute",
          right: 80,
          top: "50%",
          transform: "translateY(-50%)",
          width: 320,
          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: "#64748b", fontSize: 13, letterSpacing: 3, marginBottom: 20 }}>
          主要站點
        </div>

        {/* 垂直進度條 */}
        <div style={{ display: "flex", gap: 20 }}>
          {/* 進度條 */}
          <div
            style={{
              width: 6,
              background: "#1e3a5f",
              borderRadius: 3,
              position: "relative",
              alignSelf: "stretch",
            }}
          >
            <div
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                height: `${trainProgress * 100}%`,
                background: "linear-gradient(180deg, #f97316 0%, #ea580c 100%)",
                borderRadius: 3,
                boxShadow: "0 0 8px rgba(249,115,22,0.5)",
              }}
            />
          </div>

          {/* 站點列表 */}
          <div style={{ flex: 1 }}>
            {MAJOR-STATIONS.map((station, i) => {
              const stationIdx = HSR-STATIONS.findIndex((s) => s.name === station.name);
              const reached = trainProgress >= stationRatios[stationIdx] - 0.01;
              const stationSp = spring({
                frame: frame - i * 20,
                fps,
                config: { damping: 20, stiffness: 200 },
              });
              return (
                <div
                  key={station.name}
                  style={{
                    marginBottom: i < MAJOR-STATIONS.length - 1 ? 44 : 0,
                    opacity: interpolate(stationSp, [0, 1], [0, 1]),
                  }}
                >
                  <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                    <div
                      style={{
                        width: 16,
                        height: 16,
                        borderRadius: "50%",
                        background: reached ? "#f97316" : "#1e3a5f",
                        border: `2px solid ${reached ? "#f97316" : "#334155"}`,
                        boxShadow: reached ? "0 0 8px #f97316" : "none",
                        flexShrink: 0,
                      }}
                    />
                    <span
                      style={{
                        color: reached ? "#f1f5f9" : "#334155",
                        fontSize: 22,
                        fontWeight: reached ? 700 : 400,
                      }}
                    >
                      {station.name}
                    </span>
                  </div>
                  {i < MAJOR-STATIONS.length - 1 && (
                    <div
                      style={{
                        color: "#475569",
                        fontSize: 13,
                        marginTop: 4,
                        marginLeft: 28,
                      }}
                    >
                      {i === 0 ? "起點站" : i === 1 ? "中部幹線" : "南部幹線"}
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>

        {/* 進度數字 */}
        <div
          style={{
            marginTop: 24,
            paddingTop: 16,
            borderTop: "1px solid #1e3a5f",
            display: "flex",
            justifyContent: "space-between",
          }}
        >
          <div style={{ textAlign: "center" }}>
            <div style={{ color: "#64748b", fontSize: 12, letterSpacing: 2, marginBottom: 4 }}>
              進度
            </div>
            <div style={{ color: "#f97316", fontSize: 26, fontWeight: 700 }}>
              {Math.round(trainProgress * 100)}%
            </div>
          </div>
          <div style={{ textAlign: "center" }}>
            <div style={{ color: "#64748b", fontSize: 12, letterSpacing: 2, marginBottom: 4 }}>
              已行駛
            </div>
            <div style={{ color: "#f1f5f9", fontSize: 26, fontWeight: 700 }}>
              {Math.round(trainProgress * 345)} km
            </div>
          </div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼