Remotion LabRemotion Lab
返回模板庫

流量來源分析

桑基圖(Sankey Diagram)展示網站流量來源到落地頁面的分佈,SVG 貝茲曲線路徑寬度與流量值成比例,節點與路徑依序淡入。

圖表SVG華麗
提示詞(可直接修改內容)
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";
import React from "react";

const SOURCES = [
  { id: "搜尋引擎", value: 4200, color: "#3b82f6", y: 0.15 },
  { id: "社群媒體", value: 3100, color: "#8b5cf6", y: 0.42 },
  { id: "直接造訪", value: 2300, color: "#10b981", y: 0.65 },
  { id: "其他",    value: 1400, color: "#f59e0b", y: 0.85 },
];

const CATEGORIES = [
  { id: "首頁",   value: 3800, color: "#06b6d4", y: 0.20 },
  { id: "產品頁", value: 4500, color: "#ec4899", y: 0.52 },
  { id: "部落格", value: 2700, color: "#84cc16", y: 0.80 },
];

const FLOWS = [
  { from: "搜尋引擎", to: "首頁",   value: 1800 },
  { from: "搜尋引擎", to: "產品頁", value: 1500 },
  { from: "搜尋引擎", to: "部落格", value: 900 },
  { from: "社群媒體", to: "首頁",   value: 1200 },
  { from: "社群媒體", to: "產品頁", value: 1400 },
  { from: "社群媒體", to: "部落格", value: 500 },
  { from: "直接造訪", to: "首頁",   value: 800 },
  { from: "直接造訪", to: "產品頁", value: 1100 },
  { from: "直接造訪", to: "部落格", value: 400 },
  { from: "其他",    to: "首頁",   value: 0 },
  { from: "其他",    to: "產品頁", value: 500 },
  { from: "其他",    to: "部落格", value: 900 },
];

const W = 1920;
const H = 1080;

const COL-X = { source: 300, category: 960 };
const NODE-W = 180;
const MAX-NODE-H = 200;
const MIN-NODE-H = 30;

const MAX-SOURCE-VALUE = Math.max(...SOURCES.map((s) => s.value));
const MAX-CAT-VALUE = Math.max(...CATEGORIES.map((c) => c.value));

function nodeH(value: number, maxVal: number): number {
  return MIN-NODE-H + ((value / maxVal) * (MAX-NODE-H - MIN-NODE-H));
}

const MAX-FLOW-VALUE = Math.max(...FLOWS.filter((f) => f.value > 0).map((f) => f.value));
const MIN-STROKE = 4;
const MAX-STROKE = 60;

function flowStroke(value: number): number {
  if (value === 0) return 0;
  return MIN-STROKE + ((value / MAX-FLOW-VALUE) * (MAX-STROKE - MIN-STROKE));
}

const SOURCE-NODES = SOURCES.map((s) => ({
  ...s,
  x: COL-X.source,
  cy: s.y * H,
  h: nodeH(s.value, MAX-SOURCE-VALUE),
}));

const CAT-NODES = CATEGORIES.map((c) => ({
  ...c,
  x: COL-X.category,
  cy: c.y * H,
  h: nodeH(c.value, MAX-CAT-VALUE),
}));

const FLOW-PATHS = FLOWS.filter((f) => f.value > 0).map((flow, i) => {
  const src = SOURCE-NODES.find((s) => s.id === flow.from)!;
  const cat = CAT-NODES.find((c) => c.id === flow.to)!;
  const srcColor = src.color;

  const x1 = src.x + NODE-W;
  const y1 = src.cy;
  const x2 = cat.x;
  const y2 = cat.cy;

  const mx = (x1 + x2) / 2;

  const d = `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`;
  const stroke = flowStroke(flow.value);

  return { d, srcColor, stroke, value: flow.value, index: i };
});

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

  const titleProgress = spring({ frame, fps, config: { damping: 30, stiffness: 70 } });
  const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
  const titleY = interpolate(titleProgress, [0, 1], [-30, 0]);

  return (
    <AbsoluteFill
      style={{
        background: "#0f0f0f",
        fontFamily: "sans-serif",
      }}
    >
      {/* Title */}
      <div
        style={{
          position: "absolute",
          top: 48,
          left: 0,
          right: 0,
          textAlign: "center",
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
        }}
      >
        <div style={{ fontSize: 52, fontWeight: 700, color: "#ffffff", letterSpacing: "0.05em" }}>
          流量來源分析
        </div>
        <div style={{ marginTop: 8, fontSize: 20, color: "#6b7280", letterSpacing: "0.08em" }}>
          網站流量來源與頁面分佈桑基圖
        </div>
      </div>

      {/* Column labels */}
      {[
        { label: "流量來源", x: COL-X.source + NODE-W / 2 },
        { label: "落地頁面", x: COL-X.category + NODE-W / 2 },
      ].map(({ label, x }) => {
        const labelProgress = spring({ frame: Math.max(0, frame - 5), fps, config: { damping: 30, stiffness: 80 } });
        const labelOpacity = interpolate(labelProgress, [0, 1], [0, 1]);
        return (
          <div
            key={label}
            style={{
              position: "absolute",
              top: 134,
              left: x - 80,
              width: 160,
              textAlign: "center",
              fontSize: 20,
              fontWeight: 600,
              color: "#6b7280",
              letterSpacing: "0.06em",
              opacity: labelOpacity,
            }}
          >
            {label}
          </div>
        );
      })}

      <svg
        style={{ position: "absolute", left: 0, top: 0, width: W, height: H }}
        viewBox={`0 0 ${W} ${H}`}
      >
        {/* Flow paths */}
        {FLOW-PATHS.map((fp) => {
          const pathStartFrame = 50 + fp.index * 5;
          const pathProgress = spring({
            frame: Math.max(0, frame - pathStartFrame),
            fps,
            config: { damping: 35, stiffness: 55 },
          });
          const pathOpacity = interpolate(pathProgress, [0, 1], [0, 0.5], { extrapolateRight: "clamp" });

          return (
            <path
              key={fp.d}
              d={fp.d}
              fill="none"
              stroke={fp.srcColor}
              strokeWidth={fp.stroke}
              strokeLinecap="round"
              opacity={pathOpacity}
            />
          );
        })}

        {/* Source nodes */}
        {SOURCE-NODES.map((node, i) => {
          const nodeStartFrame = i * 5;
          const nodeProgress = spring({
            frame: Math.max(0, frame - nodeStartFrame),
            fps,
            config: { damping: 20, stiffness: 100 },
          });
          const scale = interpolate(nodeProgress, [0, 1], [0, 1], { extrapolateRight: "clamp" });
          const opacity = interpolate(nodeProgress, [0, 0.4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
          const h = node.h;
          const x = node.x;
          const y = node.cy - (h * scale) / 2;

          return (
            <React.Fragment key={node.id}>
              <rect
                x={x}
                y={y}
                width={NODE-W}
                height={h * scale}
                rx={8}
                fill={node.color}
                opacity={opacity}
                filter={`drop-shadow(0 0 12px ${node.color}88)`}
              />
              <text
                x={x - 16}
                y={node.cy + 7}
                textAnchor="end"
                fill="#e5e7eb"
                fontSize={20}
                fontWeight={600}
                opacity={opacity}
                fontFamily="sans-serif"
              >
                {node.id}
              </text>
              <text
                x={x - 16}
                y={node.cy + 28}
                textAnchor="end"
                fill="#9ca3af"
                fontSize={16}
                opacity={opacity}
                fontFamily="sans-serif"
              >
                {node.value.toLocaleString()}
              </text>
            </React.Fragment>
          );
        })}

        {/* Category nodes */}
        {CAT-NODES.map((node, i) => {
          const nodeStartFrame = 25 + i * 5;
          const nodeProgress = spring({
            frame: Math.max(0, frame - nodeStartFrame),
            fps,
            config: { damping: 20, stiffness: 100 },
          });
          const scale = interpolate(nodeProgress, [0, 1], [0, 1], { extrapolateRight: "clamp" });
          const opacity = interpolate(nodeProgress, [0, 0.4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
          const h = node.h;
          const x = node.x;
          const y = node.cy - (h * scale) / 2;

          return (
            <React.Fragment key={node.id}>
              <rect
                x={x}
                y={y}
                width={NODE-W}
                height={h * scale}
                rx={8}
                fill={node.color}
                opacity={opacity}
                filter={`drop-shadow(0 0 12px ${node.color}88)`}
              />
              <text
                x={x + NODE-W + 16}
                y={node.cy + 7}
                textAnchor="start"
                fill="#e5e7eb"
                fontSize={20}
                fontWeight={600}
                opacity={opacity}
                fontFamily="sans-serif"
              >
                {node.id}
              </text>
              <text
                x={x + NODE-W + 16}
                y={node.cy + 28}
                textAnchor="start"
                fill="#9ca3af"
                fontSize={16}
                opacity={opacity}
                fontFamily="sans-serif"
              >
                {node.value.toLocaleString()}
              </text>
            </React.Fragment>
          );
        })}
      </svg>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼