Remotion LabRemotion Lab
返回模板庫

收入結構甜甜圈圖

四個區段依序以 SVG stroke-dasharray 動畫填入,中心顯示總百分比計數,右側有圖例說明,適合展示佔比分布。

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

const SEGMENTS = [
  { label: "產品銷售", value: 42, color: "#3b82f6" },
  { label: "服務收入", value: 28, color: "#8b5cf6" },
  { label: "廣告收益", value: 18, color: "#f59e0b" },
  { label: "其他", value: 12, color: "#10b981" },
];

const RADIUS = 200;
const STROKE-WIDTH = 70;
const CENTER = 300;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

// Precompute cumulative rotations (in degrees) for each segment
const SEGMENT-ROTATIONS: number[] = [];
let cumulative = 0;
for (const seg of SEGMENTS) {
  SEGMENT-ROTATIONS.push(cumulative * 3.6 - 90); // degrees, starting from top
  cumulative += seg.value;
}

export const PieDonut: 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]);

  const centerProgress = spring({
    frame: Math.max(0, frame - 20),
    fps,
    config: { damping: 28, stiffness: 60 },
  });
  const centerScale = interpolate(centerProgress, [0, 1], [0.4, 1], {
    extrapolateRight: "clamp",
  });
  const centerOpacity = interpolate(centerProgress, [0, 0.4], [0, 1], {
    extrapolateRight: "clamp",
  });

  // Compute total displayed percentage
  const totalPercent = Math.round(
    interpolate(centerProgress, [0, 1], [0, 100], {
      extrapolateRight: "clamp",
    })
  );

  return (
    <AbsoluteFill
      style={{
        background: "#0f0f0f",
        fontFamily: "sans-serif",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      {/* Title */}
      <div
        style={{
          position: "absolute",
          top: 80,
          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>

      {/* Main layout: donut + legend */}
      <div
        style={{
          display: "flex",
          flexDirection: "row",
          alignItems: "center",
          gap: 100,
          marginTop: 40,
        }}
      >
        {/* SVG Donut */}
        <div style={{ position: "relative", width: CENTER * 2, height: CENTER * 2 }}>
          <svg
            width={CENTER * 2}
            height={CENTER * 2}
            viewBox={`0 0 ${CENTER * 2} ${CENTER * 2}`}
          >
            {/* Background track */}
            <circle
              cx={CENTER}
              cy={CENTER}
              r={RADIUS}
              fill="none"
              stroke="#1f2937"
              strokeWidth={STROKE-WIDTH}
            />

            {SEGMENTS.map((seg, index) => {
              const startFrame = index * 10;
              const segProgress = spring({
                frame: Math.max(0, frame - startFrame),
                fps,
                config: { damping: 26, stiffness: 70 },
              });
              const filledLength = interpolate(
                segProgress,
                [0, 1],
                [0, (seg.value / 100) * CIRCUMFERENCE],
                { extrapolateRight: "clamp" }
              );
              const dashArray = `${filledLength} ${CIRCUMFERENCE}`;
              const rotation = SEGMENT-ROTATIONS[index];

              return (
                <circle
                  key={seg.label}
                  cx={CENTER}
                  cy={CENTER}
                  r={RADIUS}
                  fill="none"
                  stroke={seg.color}
                  strokeWidth={STROKE-WIDTH}
                  strokeDasharray={dashArray}
                  strokeDashoffset={0}
                  strokeLinecap="butt"
                  style={{
                    transform: `rotate(${rotation}deg)`,
                    transformOrigin: `${CENTER}px ${CENTER}px`,
                    filter: `drop-shadow(0 0 12px ${seg.color}88)`,
                  }}
                />
              );
            })}
          </svg>

          {/* Center text */}
          <div
            style={{
              position: "absolute",
              inset: 0,
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              opacity: centerOpacity,
              transform: `scale(${centerScale})`,
            }}
          >
            <div
              style={{
                fontSize: 64,
                fontWeight: 700,
                color: "#ffffff",
                lineHeight: 1,
              }}
            >
              {totalPercent}%
            </div>
            <div
              style={{
                marginTop: 8,
                fontSize: 22,
                color: "#9ca3af",
                letterSpacing: "0.04em",
              }}
            >
              總佔比
            </div>
          </div>
        </div>

        {/* Legend */}
        <div
          style={{
            display: "flex",
            flexDirection: "column",
            gap: 32,
          }}
        >
          {SEGMENTS.map((seg, index) => {
            const startFrame = index * 10 + 15;
            const legendProgress = spring({
              frame: Math.max(0, frame - startFrame),
              fps,
              config: { damping: 30, stiffness: 80 },
            });
            const legendOpacity = interpolate(legendProgress, [0, 0.5], [0, 1], {
              extrapolateRight: "clamp",
            });
            const legendX = interpolate(legendProgress, [0, 1], [40, 0]);

            return (
              <div
                key={seg.label}
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 20,
                  opacity: legendOpacity,
                  transform: `translateX(${legendX}px)`,
                }}
              >
                <div
                  style={{
                    width: 28,
                    height: 28,
                    borderRadius: "50%",
                    background: seg.color,
                    boxShadow: `0 0 12px ${seg.color}88`,
                    flexShrink: 0,
                  }}
                />
                <div>
                  <div
                    style={{
                      fontSize: 28,
                      fontWeight: 600,
                      color: "#ffffff",
                    }}
                  >
                    {seg.label}
                  </div>
                  <div
                    style={{
                      fontSize: 22,
                      color: seg.color,
                      marginTop: 2,
                    }}
                  >
                    {seg.value}%
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼