Remotion LabRemotion Lab
返回模板庫

資訊架構最短路徑

以節點圖展示複雜的網站架構,接著淡出雜亂路徑,突顯從用戶到 CTA 的最短路徑,說明資訊架構設計的核心原則。

資訊架構節點圖路徑動畫網站設計
提示詞(可直接修改內容)
import React from "react";
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  spring,
} from "remotion";

const colors = {
  backgroundGradient: "linear-gradient(135deg, #0A0E14 0%, #131A24 100%)",
  accent: "#00D4AA",
  accentSecondary: "#4DA3FF",
};
const fonts = { main: "'Inter', 'Noto Sans TC', sans-serif" };

const NODES = [
  { x: 160, y: 400, label: "User", type: "start" as const },
  { x: 360, y: 180, label: "", type: "normal" as const },
  { x: 360, y: 400, label: "", type: "normal" as const },
  { x: 360, y: 600, label: "", type: "normal" as const },
  { x: 560, y: 100, label: "", type: "normal" as const },
  { x: 560, y: 280, label: "", type: "normal" as const },
  { x: 560, y: 460, label: "", type: "normal" as const },
  { x: 560, y: 640, label: "", type: "normal" as const },
  { x: 760, y: 180, label: "", type: "normal" as const },
  { x: 760, y: 360, label: "", type: "normal" as const },
  { x: 760, y: 540, label: "", type: "normal" as const },
  { x: 960, y: 120, label: "", type: "normal" as const },
  { x: 960, y: 300, label: "", type: "normal" as const },
  { x: 960, y: 480, label: "", type: "normal" as const },
  { x: 960, y: 660, label: "", type: "normal" as const },
  { x: 1160, y: 200, label: "", type: "normal" as const },
  { x: 1160, y: 400, label: "", type: "normal" as const },
  { x: 1160, y: 580, label: "", type: "normal" as const },
  { x: 1400, y: 400, label: "CTA", type: "goal" as const },
];

const COMPLEX-EDGES: [number, number][] = [
  [0, 1], [0, 2], [0, 3],
  [1, 4], [1, 5], [2, 5], [2, 6], [3, 6], [3, 7],
  [4, 8], [5, 8], [5, 9], [6, 9], [6, 10], [7, 10],
  [8, 11], [8, 12], [9, 12], [9, 13], [10, 13], [10, 14],
  [11, 15], [12, 15], [12, 16], [13, 16], [13, 17], [14, 17],
  [15, 18], [16, 18], [17, 18],
];

const SHORTEST-PATH-INDICES = [0, 2, 5, 9, 12, 16, 18];
const SHORTEST-EDGES: [number, number][] = [[0, 2], [2, 5], [5, 9], [9, 12], [12, 16], [16, 18]];

export const SHORTEST-PATH-DURATION-FRAMES = 210;

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

  const graphSpring = spring({ frame: Math.max(0, frame - 5), fps, config: { damping: 14, mass: 0.8, stiffness: 80 } });
  const graphScale = interpolate(graphSpring, [0, 1], [0.6, 1]);
  const graphOpacity = interpolate(graphSpring, [0, 0.5], [0, 1], { extrapolateRight: "clamp" });

  const getNodeDelay = (index: number) => {
    const layer = index === 0 ? 0 : index <= 3 ? 1 : index <= 7 ? 2 : index <= 10 ? 3 : index <= 14 ? 4 : index <= 17 ? 5 : 6;
    return 8 + layer * 6;
  };

  const complexFade = interpolate(frame, [70, 100], [1, 0.08], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  const nonShortestNodeFade = interpolate(frame, [70, 100], [1, 0.12], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  const getSegmentProgress = (segIndex: number) => {
    const segStart = 75 + segIndex * 8;
    return interpolate(frame, [segStart, segStart + 12], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
  };

  const glowPulse = interpolate(Math.sin(frame * 0.1), [-1, 1], [0.5, 1.0]);
  const shortestPathOpacity = interpolate(frame, [72, 85], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  const textSpring = spring({ frame: Math.max(0, frame - 130), fps, config: { damping: 10, mass: 0.5, stiffness: 120 } });
  const textScale = interpolate(textSpring, [0, 1], [0.4, 1]);
  const textOpacity = interpolate(textSpring, [0, 0.4], [0, 1], { extrapolateRight: "clamp" });

  const fadeOut = interpolate(frame, [185, 210], [1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });

  const isShortestNode = (index: number) => SHORTEST-PATH-INDICES.includes(index);
  const isShortestEdge = (from: number, to: number) => SHORTEST-EDGES.some(([a, b]) => a === from && b === to);

  return (
    <AbsoluteFill style={{ background: colors.backgroundGradient }}>
      <AbsoluteFill style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", opacity: fadeOut }}>
        <div style={{ position: "relative", width: 2340, height: 840, transform: `scale(${graphScale})`, marginBottom: 30 }}>
          <svg width="2340" height="840" viewBox="0 0 1560 760" style={{ overflow: "visible" }}>
            <defs>
              <linearGradient id="s91-pathGrad" x1="0" y1="0" x2="1" y2="0">
                <stop offset="0%" stopColor={colors.accentSecondary} />
                <stop offset="100%" stopColor={colors.accent} />
              </linearGradient>
              <filter id="s91-glow" x="-30%" y="-30%" width="160%" height="160%">
                <feGaussianBlur stdDeviation="6" result="blur" />
                <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
              </filter>
            </defs>

            {COMPLEX-EDGES.map(([from, to], i) => {
              const fromNode = NODES[from];
              const toNode = NODES[to];
              if (isShortestEdge(from, to)) return null;
              const nodeDelay = Math.max(getNodeDelay(from), getNodeDelay(to));
              const edgeAppear = spring({ frame: Math.max(0, frame - nodeDelay - 3), fps, config: { damping: 12, mass: 0.4, stiffness: 100 } });
              return (
                <line key={`edge-${i}`} x1={fromNode.x} y1={fromNode.y} x2={fromNode.x + (toNode.x - fromNode.x) * edgeAppear} y2={fromNode.y + (toNode.y - fromNode.y) * edgeAppear} stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" strokeDasharray="4 4" opacity={graphOpacity * complexFade * edgeAppear} />
              );
            })}

            {NODES.map((node, i) => {
              if (isShortestNode(i)) return null;
              const delay = getNodeDelay(i);
              const nodeSpring = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 10, mass: 0.4, stiffness: 120 } });
              const ns = interpolate(nodeSpring, [0, 1], [0, 1]);
              return (
                <g key={`node-${i}`} opacity={graphOpacity * nonShortestNodeFade * ns} transform={`translate(${node.x}, ${node.y}) scale(${ns}) translate(${-node.x}, ${-node.y})`}>
                  <circle cx={node.x} cy={node.y} r={12} fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.2)" strokeWidth="1.5" />
                </g>
              );
            })}

            {shortestPathOpacity > 0 && SHORTEST-EDGES.map(([from, to], segIndex) => {
              const fromNode = NODES[from];
              const toNode = NODES[to];
              const progress = getSegmentProgress(segIndex);
              if (progress <= 0) return null;
              const endX = fromNode.x + (toNode.x - fromNode.x) * progress;
              const endY = fromNode.y + (toNode.y - fromNode.y) * progress;
              return (
                <g key={`shortest-edge-${segIndex}`} opacity={shortestPathOpacity}>
                  <line x1={fromNode.x} y1={fromNode.y} x2={endX} y2={endY} stroke={colors.accentSecondary} strokeWidth="12" strokeLinecap="round" opacity={0.15 * glowPulse} filter="url(#s91-glow)" />
                  <line x1={fromNode.x} y1={fromNode.y} x2={endX} y2={endY} stroke="url(#s91-pathGrad)" strokeWidth="3.5" strokeLinecap="round" filter="url(#s91-glow)" opacity={glowPulse} />
                </g>
              );
            })}

            {SHORTEST-PATH-INDICES.map((nodeIndex, i) => {
              const node = NODES[nodeIndex];
              const delay = getNodeDelay(nodeIndex);
              const nodeAppearSpring = spring({ frame: Math.max(0, frame - delay), fps, config: { damping: 10, mass: 0.4, stiffness: 120 } });
              const highlightDelay = 75 + i * 8;
              const highlightSpring = spring({ frame: Math.max(0, frame - highlightDelay), fps, config: { damping: 10, mass: 0.5, stiffness: 100 } });
              const isStart = node.type === "start";
              const isGoal = node.type === "goal";
              const radius = isStart || isGoal ? 24 : 14;
              const highlightedRadius = isStart || isGoal ? 28 : 18;
              const currentRadius = interpolate(highlightSpring, [0, 1], [radius, highlightedRadius]);
              const nodeColor = interpolate(highlightSpring, [0, 1], [0, 1]);
              const fillColor = nodeColor > 0.5 ? (isGoal ? `${colors.accent}30` : `${colors.accentSecondary}30`) : "rgba(255,255,255,0.06)";
              const strokeColor = nodeColor > 0.5 ? (isGoal ? colors.accent : colors.accentSecondary) : "rgba(255,255,255,0.25)";
              const ns = interpolate(nodeAppearSpring, [0, 1], [0, 1]);
              return (
                <g key={`shortest-node-${nodeIndex}`} opacity={graphOpacity * ns} transform={`translate(${node.x}, ${node.y}) scale(${ns}) translate(${-node.x}, ${-node.y})`}>
                  <circle cx={node.x} cy={node.y} r={currentRadius} fill={fillColor} stroke={strokeColor} strokeWidth={highlightSpring > 0.5 ? 2.5 : 1.5} />
                  {isStart && <text x={node.x} y={node.y + 5} textAnchor="middle" fontSize="14" fontFamily={fonts.main} fontWeight="700" fill={nodeColor > 0.5 ? colors.accentSecondary : "rgba(255,255,255,0.5)"}>User</text>}
                  {isGoal && <text x={node.x} y={node.y + 5} textAnchor="middle" fontSize="14" fontFamily={fonts.main} fontWeight="700" fill={nodeColor > 0.5 ? colors.accent : "rgba(255,255,255,0.5)"}>CTA</text>}
                </g>
              );
            })}
          </svg>
        </div>

        {textOpacity > 0 && (
          <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 18, opacity: textOpacity, transform: `scale(${textScale})` }}>
            <span style={{ fontSize: 84, fontWeight: 800, fontFamily: fonts.main, letterSpacing: 6, background: `linear-gradient(90deg, ${colors.accentSecondary}, ${colors.accent})`, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>
              最短路徑
            </span>
          </div>
        )}
      </AbsoluteFill>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼