Remotion LabRemotion Lab
返回模板庫

散布圖

SVG 散布圖,20 個資料點分屬兩群以彈性動畫逐一彈入,點的大小對應數值權重,適合呈現兩變數間的關聯分佈。

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

const POINTS = [
  // Group A (blue) - x, y, size
  { x: 20, y: 65, size: 8, group: "A" },
  { x: 28, y: 72, size: 12, group: "A" },
  { x: 35, y: 58, size: 6, group: "A" },
  { x: 42, y: 80, size: 10, group: "A" },
  { x: 48, y: 68, size: 14, group: "A" },
  { x: 55, y: 75, size: 8, group: "A" },
  { x: 32, y: 85, size: 11, group: "A" },
  { x: 45, y: 90, size: 7, group: "A" },
  { x: 38, y: 62, size: 9, group: "A" },
  { x: 52, y: 78, size: 13, group: "A" },
  // Group B (orange)
  { x: 65, y: 35, size: 9, group: "B" },
  { x: 72, y: 28, size: 11, group: "B" },
  { x: 78, y: 42, size: 7, group: "B" },
  { x: 68, y: 22, size: 13, group: "B" },
  { x: 82, y: 38, size: 8, group: "B" },
  { x: 75, y: 48, size: 10, group: "B" },
  { x: 88, y: 32, size: 12, group: "B" },
  { x: 62, y: 45, size: 6, group: "B" },
  { x: 85, y: 25, size: 14, group: "B" },
  { x: 70, y: 55, size: 9, group: "B" },
];

const COLOR-A = "#3b82f6";
const COLOR-B = "#f59e0b";

const CHART-W = 1300;
const CHART-H = 700;
const PAD-LEFT = 90;
const PAD-RIGHT = 50;
const PAD-TOP = 40;
const PAD-BOTTOM = 80;

const PLOT-W = CHART-W - PAD-LEFT - PAD-RIGHT;
const PLOT-H = CHART-H - PAD-TOP - PAD-BOTTOM;

const GRID-TICKS = [0, 20, 40, 60, 80, 100];

function mapX(val: number): number {
  return PAD-LEFT + (val / 100) * PLOT-W;
}

function mapY(val: number): number {
  return PAD-TOP + PLOT-H - (val / 100) * PLOT-H;
}

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

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

  const gridOpacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  const legendOpacity = interpolate(frame, [80, 110], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });

  return (
    <AbsoluteFill
      style={{
        background: "#0f0f0f",
        alignItems: "center",
        justifyContent: "center",
        fontFamily: "sans-serif",
      }}
    >
      {/* Title */}
      <div
        style={{
          position: "absolute",
          top: 64,
          left: 0,
          right: 0,
          textAlign: "center",
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
        }}
      >
        <div
          style={{
            fontSize: 52,
            fontWeight: 700,
            color: "#ffffff",
            letterSpacing: "0.04em",
          }}
        >
          用戶行為散布圖
        </div>
        <div
          style={{
            marginTop: 10,
            fontSize: 22,
            color: "#6b7280",
            letterSpacing: "0.06em",
          }}
        >
          轉換率與滿意度關聯分析
        </div>
      </div>

      {/* Legend */}
      <div
        style={{
          position: "absolute",
          top: 200,
          right: (1920 - CHART-W) / 2 + PAD-RIGHT,
          display: "flex",
          gap: 32,
          opacity: legendOpacity,
        }}
      >
        {[
          { color: COLOR-A, label: "A 群組" },
          { color: COLOR-B, label: "B 群組" },
        ].map(({ color, label }) => (
          <div
            key={label}
            style={{ display: "flex", alignItems: "center", gap: 10 }}
          >
            <div
              style={{
                width: 16,
                height: 16,
                borderRadius: "50%",
                background: color,
              }}
            />
            <span style={{ fontSize: 22, color: "#d1d5db" }}>{label}</span>
          </div>
        ))}
      </div>

      {/* Chart SVG */}
      <div
        style={{
          position: "absolute",
          top: 240,
          left: (1920 - CHART-W) / 2,
          width: CHART-W,
          height: CHART-H,
        }}
      >
        <svg
          width={CHART-W}
          height={CHART-H}
          viewBox={`0 0 ${CHART-W} ${CHART-H}`}
          style={{ overflow: "visible" }}
        >
          {/* Vertical grid lines */}
          {GRID-TICKS.map((val) => {
            const x = mapX(val);
            return (
              <g key={`vgrid-${val}`} opacity={gridOpacity}>
                <line
                  x1={x}
                  y1={PAD-TOP}
                  x2={x}
                  y2={PAD-TOP + PLOT-H}
                  stroke={val === 0 ? "#4b5563" : "rgba(75,85,99,0.3)"}
                  strokeWidth={val === 0 ? 1.5 : 1}
                />
                {val > 0 && (
                  <text
                    x={x}
                    y={PAD-TOP + PLOT-H + 36}
                    textAnchor="middle"
                    fill="#9ca3af"
                    fontSize={20}
                  >
                    {val}
                  </text>
                )}
              </g>
            );
          })}

          {/* Horizontal grid lines */}
          {GRID-TICKS.map((val) => {
            const y = mapY(val);
            return (
              <g key={`hgrid-${val}`} opacity={gridOpacity}>
                <line
                  x1={PAD-LEFT}
                  y1={y}
                  x2={PAD-LEFT + PLOT-W}
                  y2={y}
                  stroke={val === 0 ? "#4b5563" : "rgba(75,85,99,0.3)"}
                  strokeWidth={val === 0 ? 1.5 : 1}
                />
                <text
                  x={PAD-LEFT - 12}
                  y={y + 7}
                  textAnchor="end"
                  fill="#9ca3af"
                  fontSize={20}
                >
                  {val}
                </text>
              </g>
            );
          })}

          {/* X-axis label */}
          <text
            x={PAD-LEFT + PLOT-W / 2}
            y={PAD-TOP + PLOT-H + 70}
            textAnchor="middle"
            fill="#6b7280"
            fontSize={22}
            opacity={gridOpacity}
          >
            轉換率 (%)
          </text>

          {/* Y-axis label */}
          <text
            x={PAD-LEFT - 64}
            y={PAD-TOP + PLOT-H / 2}
            textAnchor="middle"
            fill="#6b7280"
            fontSize={22}
            transform={`rotate(-90, ${PAD-LEFT - 64}, ${PAD-TOP + PLOT-H / 2})`}
            opacity={gridOpacity}
          >
            滿意度 (%)
          </text>

          {/* Scatter points */}
          {POINTS.map((pt, index) => {
            const startFrame = index * 5 + 10;
            const ptProgress = spring({
              frame: Math.max(0, frame - startFrame),
              fps,
              config: { damping: 18, stiffness: 120 },
            });

            const scale = interpolate(ptProgress, [0, 1], [0, 1], {
              extrapolateRight: "clamp",
            });
            const opacity = interpolate(ptProgress, [0, 0.4], [0, 1], {
              extrapolateRight: "clamp",
            });

            const cx = mapX(pt.x);
            const cy = mapY(pt.y);
            const r = pt.size;
            const color = pt.group === "A" ? COLOR-A : COLOR-B;

            return (
              <g key={index} transform={`translate(${cx}, ${cy})`} opacity={opacity}>
                {/* Glow ring */}
                <circle
                  cx={0}
                  cy={0}
                  r={r * scale * 2.2}
                  fill="none"
                  stroke={color}
                  strokeWidth={1}
                  opacity={0.25}
                />
                {/* Main dot */}
                <circle
                  cx={0}
                  cy={0}
                  r={r * scale}
                  fill={color}
                  fillOpacity={0.85}
                  stroke={color}
                  strokeWidth={1.5}
                  strokeOpacity={0.5}
                />
              </g>
            );
          })}
        </svg>
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼