Remotion LabRemotion Lab
返回模板庫

能力雷達圖

以 SVG 繪製的六軸雷達圖,呈現兩位候選人在技術、溝通、創意、執行、協作、領導六個維度的能力比較,兩個多邊形以彈性動畫從中心向外展開。

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

const AXES = ["技術", "溝通", "創意", "執行", "協作", "領導"];
const SERIES-A = { name: "候選人 A", values: [85, 72, 90, 78, 88, 65], color: "#3b82f6" };
const SERIES-B = { name: "候選人 B", values: [70, 88, 75, 92, 80, 85], color: "#f59e0b" };

const CX = 960;
const CY = 560;
const RADIUS = 300;
const GRID-LEVELS = [0.25, 0.5, 0.75, 1.0];
const N = AXES.length;

function axisAngle(i: number): number {
  return (i / N) * 2 * Math.PI - Math.PI / 2;
}

function getPoint(
  cx: number,
  cy: number,
  value: number,
  maxValue: number,
  radius: number,
  index: number
): { x: number; y: number } {
  const angle = axisAngle(index);
  const r = (value / maxValue) * radius;
  return {
    x: cx + r * Math.cos(angle),
    y: cy + r * Math.sin(angle),
  };
}

function getGridPoint(
  cx: number,
  cy: number,
  level: number,
  radius: number,
  index: number
): { x: number; y: number } {
  const angle = axisAngle(index);
  return {
    x: cx + level * radius * Math.cos(angle),
    y: cy + level * radius * Math.sin(angle),
  };
}

function pointsToString(points: { x: number; y: number }[]): string {
  return points.map((p) => `${p.x},${p.y}`).join(" ");
}

function interpolatedPoints(
  cx: number,
  cy: number,
  values: number[],
  maxValue: number,
  radius: number,
  progress: number
): { x: number; y: number }[] {
  return values.map((v, i) => {
    const full = getPoint(cx, cy, v, maxValue, radius, i);
    return {
      x: cx + (full.x - cx) * progress,
      y: cy + (full.y - cy) * progress,
    };
  });
}

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

  const titleProgress = spring({
    frame,
    fps,
    config: { damping: 30, stiffness: 70 },
  });

  const gridProgress = spring({
    frame: Math.max(0, frame - 8),
    fps,
    config: { damping: 40, stiffness: 60 },
  });

  const seriesAProgress = spring({
    frame: Math.max(0, frame - 20),
    fps,
    config: { damping: 20, stiffness: 80 },
  });

  const seriesBProgress = spring({
    frame: Math.max(0, frame - 30),
    fps,
    config: { damping: 20, stiffness: 80 },
  });

  const legendProgress = spring({
    frame: Math.max(0, frame - 50),
    fps,
    config: { damping: 28, stiffness: 90 },
  });

  const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
  const titleY = interpolate(titleProgress, [0, 1], [-30, 0]);
  const gridOpacity = interpolate(gridProgress, [0, 1], [0, 1]);
  const legendOpacity = interpolate(legendProgress, [0, 1], [0, 1]);

  const pointsA = interpolatedPoints(CX, CY, SERIES-A.values, 100, RADIUS, seriesAProgress);
  const pointsB = interpolatedPoints(CX, CY, SERIES-B.values, 100, RADIUS, seriesBProgress);

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

      {/* SVG Chart */}
      <svg
        width={1920}
        height={1080}
        style={{ position: "absolute", top: 0, left: 0 }}
        viewBox="0 0 1920 1080"
      >
        {/* Concentric hexagonal grid */}
        {GRID-LEVELS.map((level, li) => {
          const gridPoints = Array.from({ length: N }, (_, i) =>
            getGridPoint(CX, CY, level, RADIUS, i)
          );
          return (
            <polygon
              key={li}
              points={pointsToString(gridPoints)}
              fill="none"
              stroke={level === 1.0 ? "rgba(156,163,175,0.5)" : "rgba(75,85,99,0.35)"}
              strokeWidth={level === 1.0 ? 1.5 : 1}
              opacity={gridOpacity}
            />
          );
        })}

        {/* Axis lines */}
        {AXES.map((_, i) => {
          const end = getGridPoint(CX, CY, 1.0, RADIUS, i);
          return (
            <line
              key={i}
              x1={CX}
              y1={CY}
              x2={end.x}
              y2={end.y}
              stroke="rgba(75,85,99,0.45)"
              strokeWidth={1}
              opacity={gridOpacity}
            />
          );
        })}

        {/* Grid level labels (25 / 50 / 75 / 100) */}
        {GRID-LEVELS.map((level, li) => {
          const labelY = CY - level * RADIUS - 8;
          return (
            <text
              key={li}
              x={CX + 6}
              y={labelY}
              fill="#4b5563"
              fontSize={16}
              fontFamily="sans-serif"
              opacity={gridOpacity}
            >
              {Math.round(level * 100)}
            </text>
          );
        })}

        {/* Series B polygon (drawn first so A is on top) */}
        <polygon
          points={pointsToString(pointsB)}
          fill={SERIES-B.color}
          fillOpacity={0.3}
          stroke={SERIES-B.color}
          strokeWidth={2.5}
          strokeLinejoin="round"
        />

        {/* Series A polygon */}
        <polygon
          points={pointsToString(pointsA)}
          fill={SERIES-A.color}
          fillOpacity={0.3}
          stroke={SERIES-A.color}
          strokeWidth={2.5}
          strokeLinejoin="round"
        />

        {/* Data point dots — Series B */}
        {pointsB.map((p, i) => (
          <circle
            key={i}
            cx={p.x}
            cy={p.y}
            r={5}
            fill={SERIES-B.color}
            opacity={seriesBProgress}
          />
        ))}

        {/* Data point dots — Series A */}
        {pointsA.map((p, i) => (
          <circle
            key={i}
            cx={p.x}
            cy={p.y}
            r={5}
            fill={SERIES-A.color}
            opacity={seriesAProgress}
          />
        ))}

        {/* Axis labels */}
        {AXES.map((label, i) => {
          const angle = axisAngle(i);
          const labelR = RADIUS * 1.14;
          const lx = CX + labelR * Math.cos(angle);
          const ly = CY + labelR * Math.sin(angle);

          let anchor: "start" | "middle" | "end" = "middle";
          if (Math.cos(angle) > 0.2) anchor = "start";
          else if (Math.cos(angle) < -0.2) anchor = "end";

          let dy = 0;
          if (Math.sin(angle) < -0.5) dy = -8;
          else if (Math.sin(angle) > 0.5) dy = 18;

          return (
            <text
              key={i}
              x={lx}
              y={ly + dy}
              textAnchor={anchor}
              dominantBaseline="middle"
              fill="#d1d5db"
              fontSize={26}
              fontWeight={600}
              fontFamily="sans-serif"
              opacity={gridOpacity}
            >
              {label}
            </text>
          );
        })}
      </svg>

      {/* Legend */}
      <div
        style={{
          position: "absolute",
          bottom: 80,
          left: 0,
          right: 0,
          display: "flex",
          justifyContent: "center",
          gap: 60,
          opacity: legendOpacity,
        }}
      >
        {[SERIES-A, SERIES-B].map((s) => (
          <div
            key={s.name}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 14,
            }}
          >
            <div
              style={{
                width: 32,
                height: 32,
                borderRadius: 4,
                background: s.color,
                opacity: 0.85,
              }}
            />
            <span
              style={{
                fontSize: 26,
                color: "#e5e7eb",
                fontFamily: "sans-serif",
                fontWeight: 600,
              }}
            >
              {s.name}
            </span>
          </div>
        ))}
      </div>
    </AbsoluteFill>
  );
};

登入後查看完整程式碼