Remotion LabRemotion Lab
返回模板庫

現金流瀑布圖

以懸浮長條呈現各項目的累積現金流變化,正向項目顯示為綠色、負向為紅色、合計為藍色,每根長條依序以彈性動畫從底部升起,並附有虛線連接器。

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

const ITEMS-RAW = [
  { label: "期初", value: 5000, type: "total" as const },
  { label: "營收", value: 3200, type: "positive" as const },
  { label: "成本", value: -1800, type: "negative" as const },
  { label: "行銷費", value: -600, type: "negative" as const },
  { label: "利息收入", value: 400, type: "positive" as const },
  { label: "稅費", value: -520, type: "negative" as const },
  { label: "期末", value: 0, type: "total" as const },
];

// Compute cumulative bases and final values at module scope
const ITEMS = (() => {
  let cumulative = 0;
  return ITEMS-RAW.map((item, i) => {
    if (item.type === "total" && i === 0) {
      const base = 0;
      const val = item.value;
      cumulative = val;
      return { ...item, base, displayValue: val };
    }
    if (item.type === "total" && i === ITEMS-RAW.length - 1) {
      const base = 0;
      const displayValue = cumulative;
      return { ...item, base, displayValue };
    }
    const base = item.value >= 0 ? cumulative : cumulative + item.value;
    cumulative += item.value;
    return { ...item, base, displayValue: Math.abs(item.value) };
  });
})();

const Y-MAX = 9000;
const CHART-WIDTH = 1400;
const CHART-HEIGHT = 600;
const CHART-LEFT = (1920 - CHART-WIDTH) / 2;
const CHART-BOTTOM = 820;
const CHART-TOP = CHART-BOTTOM - CHART-HEIGHT;
const BAR-AREA-WIDTH = CHART-WIDTH - 80;
const BAR-GAP = 18;
const BAR-WIDTH = (BAR-AREA-WIDTH - BAR-GAP * (ITEMS.length + 1)) / ITEMS.length;
const GRID-VALUES = [0, 2000, 4000, 6000, 8000];

const COLOR-POSITIVE = "#10b981";
const COLOR-NEGATIVE = "#ef4444";
const COLOR-TOTAL = "#3b82f6";

function yPos(value: number): number {
  return CHART-TOP + CHART-HEIGHT - (value / Y-MAX) * CHART-HEIGHT;
}

function barColor(type: "positive" | "negative" | "total"): string {
  if (type === "total") return COLOR-TOTAL;
  if (type === "positive") return COLOR-POSITIVE;
  return COLOR-NEGATIVE;
}

export const WaterfallChart: 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: 60,
          left: 0,
          right: 0,
          textAlign: "center",
          opacity: titleOpacity,
          transform: `translateY(${titleY}px)`,
        }}
      >
        <div
          style={{
            fontSize: 56,
            fontWeight: 700,
            color: "#ffffff",
            letterSpacing: "0.04em",
          }}
        >
          現金流瀑布圖
        </div>
        <div
          style={{
            marginTop: 10,
            fontSize: 22,
            color: "#6b7280",
            letterSpacing: "0.06em",
          }}
        >
          各項目累積金額變化
        </div>
      </div>

      {/* Chart SVG layer (grid lines + connector lines) */}
      <svg
        width={1920}
        height={1080}
        style={{ position: "absolute", top: 0, left: 0 }}
        viewBox="0 0 1920 1080"
      >
        {/* Grid lines */}
        {GRID-VALUES.map((gv) => {
          const gy = yPos(gv);
          return (
            <g key={gv}>
              <line
                x1={CHART-LEFT + 40}
                y1={gy}
                x2={CHART-LEFT + CHART-WIDTH}
                y2={gy}
                stroke={gv === 0 ? "rgba(156,163,175,0.5)" : "rgba(75,85,99,0.3)"}
                strokeWidth={gv === 0 ? 1.5 : 1}
              />
              <text
                x={CHART-LEFT + 28}
                y={gy + 6}
                textAnchor="end"
                fill="#6b7280"
                fontSize={18}
                fontFamily="sans-serif"
              >
                {(gv / 1000).toFixed(0)}K
              </text>
            </g>
          );
        })}

        {/* Connector lines between bars */}
        {ITEMS.map((item, i) => {
          if (i === ITEMS.length - 1) return null;
          const x = CHART-LEFT + 40 + BAR-GAP + i * (BAR-WIDTH + BAR-GAP);
          const nextX = x + BAR-WIDTH + BAR-GAP;

          let connectorY: number;
          if (item.type === "total") {
            connectorY = yPos(item.displayValue);
          } else if (item.value >= 0) {
            connectorY = yPos(item.base + item.displayValue);
          } else {
            connectorY = yPos(item.base);
          }

          return (
            <line
              key={i}
              x1={x + BAR-WIDTH}
              y1={connectorY}
              x2={nextX}
              y2={connectorY}
              stroke="rgba(156,163,175,0.4)"
              strokeWidth={1.5}
              strokeDasharray="6 4"
            />
          );
        })}
      </svg>

      {/* Bars (div-based for animation) */}
      {ITEMS.map((item, index) => {
        const startFrame = index * 12;
        const barProgress = spring({
          frame: Math.max(0, frame - startFrame),
          fps,
          config: { damping: 22, stiffness: 90 },
        });

        const barHeightFull = (item.displayValue / Y-MAX) * CHART-HEIGHT;
        const barHeight = interpolate(barProgress, [0, 1], [0, barHeightFull]);

        const baseY =
          item.type === "total" && index === ITEMS.length - 1
            ? yPos(item.displayValue)
            : yPos(item.base + item.displayValue);

        const x = CHART-LEFT + 40 + BAR-GAP + index * (BAR-WIDTH + BAR-GAP);

        const labelOpacity = interpolate(barProgress, [0.4, 0.8], [0, 1], {
          extrapolateLeft: "clamp",
          extrapolateRight: "clamp",
        });

        const color = barColor(item.type);

        return (
          <div key={item.label}>
            {/* Bar */}
            <div
              style={{
                position: "absolute",
                left: x,
                top: baseY,
                width: BAR-WIDTH,
                height: barHeight,
                background: `linear-gradient(180deg, ${color}ff 0%, ${color}bb 100%)`,
                borderRadius: "4px 4px 0 0",
                boxShadow: `0 0 20px ${color}44`,
              }}
            />

            {/* Value label */}
            <div
              style={{
                position: "absolute",
                left: x,
                top: baseY - 36,
                width: BAR-WIDTH,
                textAlign: "center",
                fontSize: 22,
                fontWeight: 700,
                color: color,
                opacity: labelOpacity,
              }}
            >
              {item.value < 0 ? "-" : ""}
              {Math.abs(
                item.type === "total" && index === ITEMS.length - 1
                  ? item.displayValue
                  : item.displayValue
              ).toLocaleString()}
            </div>

            {/* X-axis label */}
            <div
              style={{
                position: "absolute",
                left: x,
                top: CHART-BOTTOM + 14,
                width: BAR-WIDTH,
                textAlign: "center",
                fontSize: 22,
                color: "#9ca3af",
                opacity: labelOpacity,
              }}
            >
              {item.label}
            </div>
          </div>
        );
      })}

      {/* Legend */}
      <div
        style={{
          position: "absolute",
          bottom: 40,
          left: 0,
          right: 0,
          display: "flex",
          justifyContent: "center",
          gap: 50,
        }}
      >
        {[
          { label: "正向項目", color: COLOR-POSITIVE },
          { label: "負向項目", color: COLOR-NEGATIVE },
          { label: "合計", color: COLOR-TOTAL },
        ].map((leg) => (
          <div
            key={leg.label}
            style={{ display: "flex", alignItems: "center", gap: 12 }}
          >
            <div
              style={{
                width: 24,
                height: 24,
                borderRadius: 4,
                background: leg.color,
              }}
            />
            <span style={{ fontSize: 22, color: "#d1d5db" }}>{leg.label}</span>
          </div>
        ))}
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼