Skip to content
Go back

React + Svg 实现环形图组件

Published:  at  12:08 PM

前置说明:Hover 按钮即可看到环形图动画效果。

一、为什么选择 SVG 方案

在实现环形进度图时,通常有两条技术路线:

方案优势劣势
CSS conic-gradient实现简单,一行代码搞定动画困难,难以精细控制分段
SVG strokeDasharray动画友好,可精确控制每段圆弧的起止需要一定的几何计算

当需求涉及”从 0% 动画到目标值”的进入效果,或需要分段环形图(每段独立颜色和占比)时,SVG 方案的优势就体现出来了。

SVG 的 circle 元素通过 stroke-dasharray 控制”描边长度”,配合 stroke-dashoffset 控制”起点偏移”,就能绘制任意长度的圆弧。


二、核心类型定义

type RingPieItem = {
  value: number; // 数值(绝对值)
  color: string; // 颜色
  label: string; // 标签文本
  hideLabel?: boolean; // 是否隐藏图例
};

数据模型非常简洁:一个分段只需要知道”数值”、“颜色”、“标签”三个必填字段,以及一个可选的”隐藏图例”开关。


三、几何计算

这部分是整个组件的核心难点。我们要把一堆数值转换成 SVG 能画的东西,需要三步走:

3.1 预处理:圆环的基础参数

const radius =
  ringRadius / 2 < strokeWitdh
    ? ringRadius / 2 - 2
    : ringRadius / 2 - strokeWitdh / 2;
const stroke = ringRadius / 2 < strokeWitdh ? 2 : strokeWitdh;
const centerPointer = ringRadius / 2;

3.2 计算每个分段的起始角度

let angle = startAngle - 90;
const total = patterns.reduce((acc, cur) => acc + cur.value, 0);

patternsData = patterns.map((item, index) => {
  const preItemPercent =
    total === 0 ? 0 : (patterns[index - 1] || { value: 0 }).value / total;
  const percent = item.value / total;
  // ...
  angle += preItemPercent * 360; // 累计角度
  // ...
});

这里有一个关键细节

3.3 分段间隙的处理

const gapPercent = 0.005; // 间隙占周长的 0.5%
const gapAngle = gapPercent * 360; // 转换为角度

每个分段之间留 0.5% 的间隙,防止颜色粘连。

3.4 计算分割线的端点坐标

这是最抽象的部分——我们需要在每个分段之间画一条白色分割线,而分割线的坐标需要三角函数来计算:

x1 =
  centerPointer + Math.sin(((angle - gapAngle / 2) / 180) * Math.PI) * radius;
y1 =
  centerPointer - Math.cos(((angle - gapAngle / 2) / 180) * Math.PI) * radius;
x2 =
  centerPointer + Math.sin(((angle + gapAngle / 2) / 180) * Math.PI) * radius;
y2 =
  centerPointer - Math.cos(((angle + gapAngle / 2) / 180) * Math.PI) * radius;

几何解释

         (x1, y1)
            .
           /  ← 间隙角的一半
          /
(x, y) ———————→ (3点钟方向 = 0°)
          \
           \
            .
         (x2, y2)

四、图例百分比精度处理

图例中的百分比直接用 (value / total) * 100 会有浮点精度问题,导致所有分段加起来不等于 100%。解决方案:

// 第一遍:计算原始百分比(保留4位小数精度)
let legendValue = Math.floor((item.value / total) * 10000) / 100;

// 第二遍:第一个非零分段补偿误差
if (!further && item.value !== 0) {
  further = true;
  legendValue =
    (10000 -
      patternsData.reduce(
        (i, j, k) => i + (index === k ? 0 : j.legendValue * 100),
        0
      )) /
    100;
}

思路:先用 Math.floor 保留两位小数(乘以 10000 再除以 100),然后让第一个非零分段来补偿剩余误差,确保总和为 100%。


五、SVG 圆弧绘制与动画

5.1 strokeDasharray 的工作原理

strokeDasharray={`${3.1415 * radius * 2 * (anim ? percent : initPercent)} ${3.1415 * radius * 2}`}

stroke-dasharray 的语法是两个值

[可见段长度] [不可见段(间隙)长度]

初始状态 initPercent = 0,动画触发后变为 percent,配合 CSS transition 产生动画效果。

5.2 旋转定位

style={{
  transition: 'stroke-dasharray 0.2s ease-in-out',
  transform: `rotate(${angle}deg)`,
  transformOrigin: 'center'
}}

每个 <circle> 元素绕自身圆心旋转到对应的角度。由于 stroke-dasharray 的”起点”默认在右侧(0°),所以旋转角度 angle 就决定了哪段圆弧是可见的

5.3 分割线渲染

{
  anim &&
    patternsData.map(({ x1, x2, y1, y2 }, index) => (
      <line {...{ x1, x2, y1, y2 }} />
    ));
}

六、动画触发机制

const [anim, setAnim] = useState(false);
useEffect(() => {
  setTimeout(() => {
    setAnim(visible);
  }, 100);
}, [visible]);

visible prop 控制显示/隐藏,但加了 100ms 延迟,原因:

  1. 防止 Hover 卡顿时频繁闪烁
  2. 让 CSS transition 有执行时间差(先设置 visible → 再触发 anim

图例的动画效果配合得很好:

style={{
  opacity: anim ? 1 : 0,
  transform: `translateX(${anim ? 0 : -8}px)`,
  transition: 'all 0.35s ease-in-out'
}}

淡入 + 轻微位移,让图例的出现更自然。


七、完整使用示例

// RingPie.tsx
import { useEffect, useState, useMemo } from "react";

type RingPieItem = {
  value: number;
  color: string;
  label: string;
  hideLabel?: boolean;
};

type RingPieProps = {
  ringRadius?: number;
  strokeWidth?: number;
  startAngle?: number;
  patterns: RingPieItem[];
  visible: boolean;
};

type PatternData = RingPieItem & {
  angle: number;
  percent: number;
  legendValue: number;
  preItemPercent: number;
  initPercent: number;
  x1: number;
  y1: number;
  x2: number;
  y2: number;
};

const GAP_PERCENT = 0.005;
const ANIMATION_DURATION_MS = 100;
const CIRCLE_CIRCUMFERENCE_MULTIPLIER = 2 * Math.PI;
const PERCENTAGE_PRECISION = 10000;
const DEGREES_IN_CIRCLE = 360;
const SVG_ANGLE_OFFSET = 90;

const calculateGapAngle = (gapPercent: number): number =>
  gapPercent * DEGREES_IN_CIRCLE;

const calculateTotal = (patterns: RingPieItem[]): number =>
  patterns.reduce((sum, item) => sum + item.value, 0);

const calculateRadius = (ringRadius: number, strokeWidth: number): number => {
  const halfRadius = ringRadius / 2;
  return halfRadius < strokeWidth
    ? halfRadius - 2
    : halfRadius - strokeWidth / 2;
};

const calculateStrokeWidth = (
  ringRadius: number,
  strokeWidth: number
): number => (ringRadius / 2 < strokeWidth ? 2 : strokeWidth);

const degreesToRadians = (degrees: number): number => (degrees / 180) * Math.PI;

const calculatePointOnCircle = (
  centerX: number,
  centerY: number,
  radius: number,
  angle: number
): { x: number; y: number } => ({
  x: centerX + Math.sin(degreesToRadians(angle)) * radius,
  y: centerY - Math.cos(degreesToRadians(angle)) * radius,
});

const calculateLegendValue = (value: number, total: number): number =>
  total === 0 ? 0 : Math.floor((value / total) * PERCENTAGE_PRECISION) / 100;

const adjustLegendValuesForRounding = (
  patternsData: PatternData[]
): PatternData[] => {
  let hasAdjusted = false;

  return patternsData.map((item, index) => {
    if (hasAdjusted || item.value === 0) {
      return item;
    }

    hasAdjusted = true;
    const sumOfOthers = patternsData.reduce(
      (sum, other, otherIndex) =>
        sum + (index === otherIndex ? 0 : other.legendValue * 100),
      0
    );

    return {
      ...item,
      legendValue: (PERCENTAGE_PRECISION - sumOfOthers) / 100,
    };
  });
};

export default function RingPie({
  ringRadius = 100,
  strokeWidth = 4,
  startAngle = 0,
  patterns,
  visible,
}: RingPieProps) {
  const [isAnimated, setIsAnimated] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => {
      setIsAnimated(visible);
    }, ANIMATION_DURATION_MS);

    return () => clearTimeout(timer);
  }, [visible]);

  const calculations = useMemo(() => {
    const radius = calculateRadius(ringRadius, strokeWidth);
    const effectiveStrokeWidth = calculateStrokeWidth(ringRadius, strokeWidth);
    const center = ringRadius / 2;
    const gapAngle = calculateGapAngle(GAP_PERCENT);
    const total = calculateTotal(patterns);

    let currentAngle = startAngle - SVG_ANGLE_OFFSET;
    const patternsData: PatternData[] = patterns.map((item, index) => {
      const previousItem = patterns[index - 1] || { value: 0 };
      const previousPercent = total === 0 ? 0 : previousItem.value / total;
      const percent = item.value / total;
      const legendValue = calculateLegendValue(item.value, total);

      currentAngle += previousPercent * DEGREES_IN_CIRCLE;

      const startGapAngle = currentAngle - gapAngle / 2;
      const endGapAngle = currentAngle + gapAngle / 2;
      const startPoint = calculatePointOnCircle(
        center,
        center,
        radius,
        startGapAngle
      );
      const endPoint = calculatePointOnCircle(
        center,
        center,
        radius,
        endGapAngle
      );

      return {
        ...item,
        angle: currentAngle,
        percent,
        legendValue,
        preItemPercent: previousPercent,
        initPercent: 0,
        x1: startPoint.x,
        y1: startPoint.y,
        x2: endPoint.x,
        y2: endPoint.y,
      };
    });

    const adjustedPatternsData = adjustLegendValuesForRounding(patternsData);

    return {
      radius,
      effectiveStrokeWidth,
      center,
      total,
      patternsData: adjustedPatternsData,
    };
  }, [ringRadius, strokeWidth, startAngle, patterns]);

  const { radius, effectiveStrokeWidth, center, patternsData } = calculations;

  const renderCircle = (pattern: PatternData, index: number) => {
    const circumference = CIRCLE_CIRCUMFERENCE_MULTIPLIER * radius;
    const dashArray = `${circumference * (isAnimated ? pattern.percent : pattern.initPercent)} ${circumference}`;

    return (
      <circle
        key={index}
        cx={center}
        cy={center}
        r={radius}
        strokeWidth={effectiveStrokeWidth}
        stroke={pattern.color}
        fill="none"
        style={{
          transition: "stroke-dasharray 0.2s ease-in-out",
          transform: `rotate(${pattern.angle}deg)`,
          transformOrigin: "center",
        }}
        strokeDasharray={dashArray}
      />
    );
  };

  const renderGapLine = (pattern: PatternData, index: number) => {
    if (!isAnimated) return null;

    return (
      <line
        key={`gap-${index}`}
        x1={pattern.x1}
        x2={pattern.x2}
        y1={pattern.y1}
        y2={pattern.y2}
        stroke="#fff"
        strokeWidth={effectiveStrokeWidth}
        style={{
          position: "relative",
          zIndex: 100,
          transform: `rotate(90deg)`,
          transformOrigin: "center",
        }}
      />
    );
  };

  const renderLegendItem = (pattern: PatternData) => {
    if (pattern.hideLabel) return null;

    return (
      <div
        key={pattern.label}
        className="legend-item"
        style={{ height: 18, whiteSpace: "nowrap" }}
      >
        <div
          className="legend-item-color"
          style={{
            background: pattern.color,
            display: "inline-block",
            width: 14,
            height: 8,
            borderRadius: 2,
            marginRight: 8,
          }}
        />
        <div
          className="legend-item-text"
          style={{
            display: "inline-block",
            width: 14,
            textAlign: "center",
            color: "rgba(0, 0, 0, 0.85)",
            marginRight: 8,
            fontSize: 12,
          }}
        >
          {pattern.label}
        </div>
        <div
          className="legend-item-value"
          style={{
            display: "inline-block",
            color: "rgba(0, 0, 0, 0.85)",
            fontSize: 12,
            textAlign: "left",
          }}
        >
          {pattern.legendValue}%
        </div>
      </div>
    );
  };

  return (
    <div
      style={{
        display: "flex",
        flexWrap: "nowrap",
        justifyContent: "space-between",
      }}
    >
      <div style={{ flexBasis: ringRadius }}>
        <svg
          style={{ width: ringRadius, height: ringRadius }}
          viewBox={`0 0 ${ringRadius} ${ringRadius}`}
          version="1.1"
          xmlns="http://www.w3.org/2000/svg"
        >
          {patternsData.map(renderCircle)}
          {patternsData.map(renderGapLine)}
        </svg>
      </div>

      <div
        className="legend"
        style={{
          transition: "all 0.35s ease-in-out",
          opacity: isAnimated ? 1 : 0,
          transform: `translateX(${isAnimated ? 0 : -8})`,
          marginTop: -5,
          display: "flex",
          flexDirection: "column",
          paddingBottom: 3,
          justifyContent: "space-between",
          marginLeft: "24px",
        }}
      >
        {patternsData.map(renderLegendItem)}
      </div>
    </div>
  );
}


// index.tsx
const patterns = [
  { color: "#74D1B8", value: 60, label: "" },
  { color: "#EBD03D", value: 10, label: "" },
  { color: "#FFB06F", value: 10, label: "" },
  { color: "#FE6B4A", value: 10, label: "" },
];

<RingPie
  ringRadius={88} // SVG 尺寸
  strokeWitdh={14} // 描边线宽(也是圆环粗细)
  patterns={patterns}
  visible={isHovered} // 控制动画触发
/>;

八、总结

SVG 的 stroke-dasharray 是一个被低估的属性。它本质上是用”描边长度”来控制图形,和 CSS 的 clip-path 类似,但更强大。结合三角函数做几何计算,你几乎可以用 SVG 画出任何你想要的弧形效果。

环形图只是一个起点,掌握了这个思路,还可以做:

核心公式stroke-dasharray = 周长 × 占比 + rotate = 累计角度



Previous Post
Typescript
Next Post
Promise的实现原理详解