一文掌握SVG雷达图的制作!高中数学知识终于派上用场了

前沿

在上一篇 # 轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然 中,我们介绍了使用svg柱形图和折线图的制作。不要停下脚步,这次我们将介绍雷达图。
雷达图是一种基于极坐标系的数据可视化图表,通过多边形和直线的组合呈现出数据在不同维度上的分布情况,具有直观性和可比性。在数据报告、市场调研、竞争分析等领域中,雷达图是一个非常重要的数据呈现方式。

展示

先看完整的效果展示,也可以前往 zeng-j.github.io/react-svg-c… 查看。

radar.gif

开始

话不多说,我们行动。封装图表,我们先约定一些图表的配置数据。

属性 说明
width 图表宽度 640
height 图表高度 480
labelFontSize 标签字体大小 12
padding 内边距,坐标轴文本占用 36
yMaxValue Y 轴最大值 100
yTickCount Y 轴刻度数量 5

我们再根据配置计算一些必要值。

const radius = (height - 2 * padding) / 2;
const centerX = width / 2;
const centerY = height / 2;
// y轴刻度线单位值
const yUnit = yMaxValue / (yTickCount - 1);
// y轴刻度线
const yTicks = Array.from({ length: yTickCount }).map((_, i) => yUnit * i);

结合图例来看下计算值的意思

radar_illustration1.png

y轴

接下来先从基础绘制开始,我们先把y轴的刻度线画了,也就是从圆心开始向外扩散一圈一圈的圆。
上代码,不解释ts类型了,可忽略。

function RadarChart({ config }: RadarChartProps) {
  const { width, height, yMaxValue, yTickCount, labelFontSize, padding } = config;




  const radius = (width - 2 * padding) / 2;
  const centerX = width / 2;
  const centerY = height / 2;
  // y轴刻度线单位值
  const yUnit = yMaxValue / (yTickCount - 1);
  // y轴刻度线
  const yTicks = Array.from({ length: yTickCount }).map((_, i) => yUnit * i);


  // y轴坐标系
  const yCoordinateAxisNode = useMemo(() => {
    return (
      <g>
        {yTicks.map((yValue, index) => {
          // 当前刻度的半径
          const curTickRaius = (yValue / yMaxValue) * radius;
          return (
            <g key={curTickRaius}>
              {index > 0 && (
                <circle
                  cx={centerX}
                  cy={centerY}
                  r={curTickRaius}
                  fill="none"
                  stroke="#E1E8F7"
                  strokeWidth="1"
                />
              )}
              {/* y轴文本 */}
              <text
                x={centerX - 6}
                y={centerY - curTickRaius}
                fill="#4D535C"
                fontSize={labelFontSize}
                dominantBaseline="central"
                style={{ textAnchor: 'end' }}
              >
                {yValue}
              </text>
            </g>
          );
        })}
      </g>
    );
  }, [centerX, centerY, labelFontSize, radius, yMaxValue, yTicks]);

  return (
    <svg width={width} height={height}>
      {/* y轴 */}
      {yCoordinateAxisNode}
    </svg>
  );
}

显示如下

radar_demo1.png

x轴

画x轴前我们就要先知道数据源是啥,我们约定数据源格式如下。

const data = [
  {
    label: '数据结构',
    value: [
      { name: '我的正确率', value: 70 },
      { name: '平均正确率', value: 43 },
    ],
  },
  {
    label: '算法',
    value: [
      { name: '我的正确率', value: 67 },
      { name: '平均正确率', value: 43 },
    ],
  },
  {
    label: '计算机基础',
    value: [
      { name: '我的正确率', value: 95 },
      { name: '平均正确率', value: 55 },
    ],
  },
  {
    label: 'C++程序设计',
    value: [
      { name: '我的正确率', value: 94 },
      { name: '平均正确率', value: 85 },
    ],
  },
  {
    label: '计算机网络',
    value: [
      { name: '我的正确率', value: 92 },
      { name: '平均正确率', value: 82 },
    ],
  },
];


上面举例我们x轴要展示数据结构、算法、计算机基础、C++程序设计、计算机网络 5项数据,并且两组数据对比(我的正确率和平均正确率)。

接下来计算x轴每个刻度点在svg中坐标位置(注意我们要区分我们需要绘制图表的极坐标系和svg本身的坐标系)。

先给出图例

radar_illustration2.png

就是说,如果已知角度、圆心、半径,就能计算坐标点的位置。我们来封装一下函数。我们以圆心为原点再模拟一个直角坐标系,角度以y正向轴为起始,顺时针旋转。

/** 根据极坐标的坐标计算在svg中的坐标 */
export const calcSvgCoordinateByPolar = (
  radius: number,
  angle: number,
  centerX: number,
  centerY: number,
) => ({
  xPosition: centerX + radius * Math.sin(angle),
  yPosition: centerY - radius * Math.cos(angle),
  angle,
});

紧接着我们进一步封装绘制x轴刻度线与文本的函数。

// 生成x轴的刻度线与文本
function generateXCoordinateTick(
  data: DataListItem[],
  { radius, centerX, centerY, padding }: GenerateXCoordinateTick,
) {
  const len = data.length;
  // 平分角度

  const angleUnit = (Math.PI * 2) / len;





  return data.map((item, index) => {
    const angle = angleUnit * index;
    const { xPosition: tickXPosition, yPosition: tickYPosition } = calcSvgCoordinateByPolar(
      radius,
      angle,
      centerX,
      centerY,
    );
    // x坐标轴文本 比 刻度点半径多padding / 2
    const { xPosition: tickLabelXPosition, yPosition: tickLabelYPosition } =
      calcSvgCoordinateByPolar(radius + padding / 2, angle, centerX, centerY);
    return {
      label: item.label,
      tickXPosition,
      tickYPosition,
      tickLabelXPosition,
      tickLabelYPosition,
      angle,
    };
  });
}

接下来正式绘制

function RadarChart({ data, config }: RadarChartProps) {


  // ...






  // x轴坐标系
  const xCoordinateAxisNode = useMemo(() => {
    const xCoordinateTicks = generateXCoordinateTick(data, { radius, centerX, centerY, padding });
    return (
      <g>
        {xCoordinateTicks.map((item) => {
          // 刻度线的角度不同,对齐方式也不同
          let textAnchor: CSSProperties['textAnchor'] = 'end';
          if (item.angle === 0 || item.angle === Math.PI) {
            textAnchor = 'middle';
          } else if (item.angle < Math.PI) {
            textAnchor = 'start';
          }
          return (
            <g key={item.angle}>
              {/* x轴刻度线 */}
              <line
                x1={centerX}
                x2={item.tickXPosition}
                y1={centerY}
                y2={item.tickYPosition}
                stroke="#E1E8F7"
                strokeWidth="1"
              />
              {/* x轴文本 */}
              <text
                x={item.tickLabelXPosition}
                y={item.tickLabelYPosition}
                fill="#4D535C"
                fontSize={labelFontSize}
                dominantBaseline="central"
                style={{ textAnchor }}
              >
                {item.label}
              </text>
            </g>
          );
        })}
      </g>
    );
  }, [centerX, centerY, data, labelFontSize, padding, radius]);

  return (
    <svg width={width} height={height}>
      {/* x轴 */}
      {xCoordinateAxisNode}
    </svg>
  );
}

展示如下

radar_demo2.png

数据展示

数据展示也是同样要计算每个点的在svg中坐标位置。同样使用上面已经封装好的函数calcSvgCoordinateByPolar。基于这个函数再封装一个处理数据源的函数。

function generateChartData(
  list: DataListItem[],
  { centerX, centerY, yMaxValue, radius }: RadarGenerateDataConfigType,
): RadarChartDataListItem[] {
  const chartData: RadarChartDataListItem[] = [];
  const len = list.length;
  // 平分角度

  const angleUnit = (Math.PI * 2) / len;





  for (let i = 0; i < len; i++) {
    const item = list[i];
    let category: RadarCategoryType[] = [];

    const angle = angleUnit * i;

    // 多组
    if (Array.isArray(item.value)) {
      category = item.value.map((group) => ({
        ...group,
        ...calcSvgCoordinateByPolar((group.value / yMaxValue) * radius, angle, centerX, centerY),
      }));
    } else if (Object.prototype.toString.call(item.value) === '[object Object]') {
      // 一组
      category = [
        {
          ...item.value,
          ...calcSvgCoordinateByPolar(
            (item.value.value / yMaxValue) * radius,
            angle,
            centerX,
            centerY,
          ),
        },
      ];
    } else {
      throw new Error('value必须为对象或者数组');
    }


    chartData.push({
      angle,
      category,
      label: item.label,
    });
  }
  return chartData;
}

如上,我们考虑单组或多组图表的情况。把计算的数据用 category 字段存起来。下面的绘制思路就是,把每组对应坐标点连接起来,成一个多边形形状,这意味我们使用 polygon 标签进行绘制。

const colors = ['#14DE95', '#3098FF'];


function RadarChart({ data, config }: RadarChartProps) {
  // ...


  const polygonNode = useMemo(() => {
    if (chartData.length <= 0) {
      return null;
    }
    const { category } = chartData[0];
    return (
      <g>
        {category.map((categoryItem, index: number) => (
          <polygon
            key={categoryItem.name}
            points={chartData
              .map((item) => `${item.category[index].xPosition} ${item.category[index].yPosition} `)
              .join('')}
            stroke={colors[index]}
            fill={colors[index]}
            fillOpacity="0.2"
            strokeWidth="1"
          />
        ))}
      </g>
    );
  }, [chartData]);

  return (
    <svg width={width} height={height}>
      {/* x轴 */}
      {xCoordinateAxisNode}
      {/* y轴 */}
      {yCoordinateAxisNode}
      {/* 数据 */}
      {polygonNode}
    </svg>
  );
}

展示效果如下。

radar_demo3.png

雷达图基本绘制就算完成啦!!!

hover辅助线

下面我们来添加一些交互,让交互体验更加友好。

先看展示效果

radar_demo4.gif

思路很简单,就是给svg元素添加鼠标事件,绘制鼠标位置到圆心的虚线。

function RadarChart({ data, config }: RadarChartProps) {


  // ...






  const crosshairsRef = useRef<SVGGElement>(null);


  const showCrosshairs = (x: number, y: number) => {
    // 第一次才渲染
    if (crosshairsRef.current) {
      const d = `M ${centerX} ${centerY} ${x} ${y}`;
      if (crosshairsRef.current.firstChild) {
        crosshairsRef.current.children[0].setAttribute('d', d);
      } else {
        crosshairsRef.current.innerHTML = `
          <path
            d="${d}"
            stroke="#B0BFE1"
            stroke-width="1"
            stroke-dasharray="4, 4"
          />
        `;
      }
      crosshairsRef.current?.setAttribute('style', 'visibility: visible;');
    }
  };

  const hiddenCrosshairs = () => {
    crosshairsRef.current?.setAttribute('style', 'visibility: hidden;');
  };


  const handleMouseMove = useThrottle(
    (e: MouseEvent) => {
      // 是否在极坐标内,这个后面实现
      const { x, y, isWithin } = isWithinOrNotOfPolar(e, {
        centerX,
        centerY,
        radius,
      });
      
      if (isWithin) {
        showCrosshairs(x, y);
      } else {
        hiddenCrosshairs();
      }
    },
    { wait: 50, trailing: false },
  );

  const handleMouseLeave = () => {
    hiddenCrosshairs();
  };

  return (
    <svg
      width={width}
      height={height}
      onMouseMove={handleMouseMove.run}
      onMouseLeave={handleMouseLeave}
    >
      {/* x轴 */}
      {xCoordinateAxisNode}
      {/* y轴 */}
      {yCoordinateAxisNode}
      {/* 数据 */}
      {polygonNode}
      {/* 辅助线 */}
      <g ref={crosshairsRef} />
    </svg>
  );
}

可以看上面代码,给鼠标事件加了节流,同时用isWithinOrNotOfPolar判断鼠标是否位于极坐标内才显示辅助线。

// 判断鼠标是否在极坐标系内
const isWithinOrNotOfPolar = (
  e: MouseEvent,
  {
    centerX,
    centerY,
    radius,
  }: {
    centerX: number;
    centerY: number;
    radius: number;
  },
) => {
  const rect = e.currentTarget?.getBoundingClientRect();
  const { clientX, clientY } = e;
  // 鼠标在svg内的位置
  const x = clientX - rect.left;
  const y = clientY - rect.top;

  // 根据两个坐标点的距离,判断鼠标移动是否在图表范围内
  const curRadius = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);


  return {
    x,
    y,
    clientX,
    clientY,
    isWithin: curRadius <= radius,
  };
};

hover辅助点

我们进一步加一下hover辅助点,这个就麻烦一点,因为需要计算鼠标位于哪个刻度线范围。

先看下效果

radar_demo5.gif

思考下,我们知道鼠标在svg元素内的坐标位置,我们要怎么计算它位于哪个刻度区域内呢?

我们回顾下数学知识中的三角函数之正切函数。它的反函数为反正切函数————y = arctan(x),值域为(-π/2,π/2)

image.png

就是说我们知道坐标点,我们就能知道坐标点到圆心 与 x正向轴的角度,但是范围为(-π/2,π/2)

但是我们需要知道360度的角度才能够完整判断怎么办?

还好 Javascript 提供了 Math.atan2()函数。Math.atan2()  返回从原点 (0,0) 到 (x,y) 点的线段与 x 轴正方向之间的平面角度 (弧度值),就是 Math.atan2(y,x)(注意传入的是先y后x)。范围为(-π,π)

我们需要把(-π,π)变成(0,2π)。例如下图所示

radar_illustration3.png

封装成函数就是如下

const calcAngleByCoordinate = (x: number, y: number): number => {
  let angle = Math.atan2(y, x);
  if (angle < 0) {
    angle += Math.PI * 2;
  }
  return angle;
};

但是还有个问题,算出来的角度是正 X 轴和坐标点(x, y)与原点连线之间的夹角,逆时针方向。而我们画的图表是以正 Y 轴起始,顺时针方向。 这个要怎么解决?

说起来也算简单,我把x轴与y轴反转一下。如下图所示。

radar_illustration4.png

也是说一个坐标点 (x, y),正常使用calcAngleByCoordinate(x, y),我们现在把两个参数调换calcAngleByCoordinate(y, x)即可。

现在已知坐标点,我们能够计算出角度了。

稍等,还还还有最后一个问题,先看下图。

radar_illustration5.png

鼠标在第一个刻度区域范围内如上面左图显示,第二个刻度区域及后续的以此类推。而我们上面计算的角度是正 Y 轴起始,顺时针方向。所以我们再稍微调整一下,就是逆时针旋转【平均刻度角度的一半】。

我们结合看代码了。

// 判断当前鼠标位置位于哪个刻度区域内,返回索引值
const whereIsAreaOfPolar = (
  svgX: number,
  svgY: number,
  centerX: number,
  centerY: number,
  angleUnit: number,
): number => {
  // 假设我们现在以圆心作为原点,模拟一个直角坐标系,求出鼠标位置的坐标点
  const x = svgX - centerX;
  const y = centerY - svgY;
  // 求出鼠标位置与 y 正向轴的角度(顺时针)
  const initAngle = calcAngleByCoordinate(y, x);
  // 再将角度逆时针旋转 angleUnit / 2,因为第一个刻度的区域为[-angleUnit / 2, angleUnit / 2]
  const angle = (initAngle + angleUnit / 2) % (Math.PI * 2);
  let findIndex = 0;
  while (true) {
    if (angle >= findIndex * angleUnit && angle <= (findIndex + 1) * angleUnit) {
      break;
    }

    // 这个一般不会发生,防止一下,避免卡死
    if (findIndex * angleUnit > Math.PI * 2) {
      break;
    }
    findIndex += 1;
  }
  return findIndex;
};


function RadarChart({ data, config }: RadarChartProps) {
  // ...

  const dotRef = useRef<SVGGElement>(null);



  const showDot = (item: RadarChartDataListItem) => {
    if (dotRef.current) {
      if (dotRef.current.children.length > 0) {
        Array.prototype.map.call(dotRef.current.children, (g, index) => {
          const categoryItem = item.category[index];
          g.children[0]?.setAttribute('cx', categoryItem.xPosition);
          g.children[0]?.setAttribute('cy', categoryItem.yPosition);
          g.children[1]?.setAttribute('cx', categoryItem.xPosition);
          g.children[1]?.setAttribute('cy', categoryItem.yPosition);
        });
      } else {
        dotRef.current.innerHTML = item.category
          .map(
            // 第一个circle为点的边框,第二个为圆心
            (sub, i) => `
              <g>
                <circle r="6" cx="${sub.xPosition}" cy="${sub.yPosition}" fill="#fff" />
                <circle r="4" cx="${sub.xPosition}" cy="${sub.yPosition}" fill="${colors[i]}" />
              </g>
             `,
          )
          .join('');
      }
      dotRef.current?.setAttribute('style', 'visibility: visible;');
    }
  };

  const hiddenDot = () => {
    dotRef.current?.setAttribute('style', 'visibility: hidden;');
  };

  const handleMouseMove = useThrottle(
    (e: MouseEvent) => {
      const { x, y, isWithin } = isWithinOrNotOfPolar(e, {
        centerX,
        centerY,
        radius,
      });
      if (isWithin) {
        // 平分角度
        const angleUnit = (Math.PI * 2) / chartData.length;
        // 判断鼠标位置鼠标位于哪个x轴刻度区域内
        const index = whereIsAreaOfPolar(x, y, centerX, centerY, angleUnit);
        const currentItem = chartData[index];
        showCrosshairs(x, y);
        showDot(currentItem);
      } else {
        hiddenCrosshairs();
        hiddenDot();
      }
    },
    { wait: 50, trailing: false },
  );

  const handleMouseLeave = () => {
    hiddenCrosshairs();
    hiddenDot();
  };

  return (
    <svg
      width={width}
      height={height}
      onMouseMove={handleMouseMove.run}
      onMouseLeave={handleMouseLeave}
    >
      {/* x轴 */}
      {xCoordinateAxisNode}
      {/* y轴 */}
      {yCoordinateAxisNode}
      {/* 数据 */}
      {polygonNode}
      {/* 辅助线 */}
      <g ref={crosshairsRef} />
      {/* 辅助点 */}
      <g ref={dotRef} />
    </svg>
  );
}

回顾思路就是鼠标移动时,我们需要计算鼠标在svg元素内的坐标点,再求出角度,然后判断在哪个刻度区域内,然后在对应刻度线上绘制辅助点。

hover显示提示窗

这个在 上一篇文章 讲过 ,可以前往查看。竟然逻辑比较通用,那么我们就封装成一个hook吧。

import { RefObject, useCallback, useRef } from 'react';


import type { CommonChartDataListItem } from '../data';

interface UseChartTooltipsProps {
  containerRef: RefObject<HTMLElement>;
  colors: string[];
}




const TOOLTIPS_CLASS_PREFIX = 'rsc-tooltips';


// 图表的提示弹窗hooks
export default function useChartTooltips<
  T extends CommonChartDataListItem = CommonChartDataListItem,
>({ containerRef, colors }: UseChartTooltipsProps) {
  const tooltipsRef = useRef<HTMLDivElement>();
  const handleHiddenTooltips = useCallback(() => {
    if (tooltipsRef.current) {
      tooltipsRef.current.style.visibility = 'hidden';
    }

  }, []);


  const handleShowTooltips = (clientX: number, clientY: number, currentItem?: T) => {
    if (!containerRef.current) {
      return;
    }

    // 挂载提示窗
    if (!tooltipsRef.current) {
      tooltipsRef.current = document.createElement('div');
      tooltipsRef.current.setAttribute('class', TOOLTIPS_CLASS_PREFIX);
      containerRef.current.appendChild(tooltipsRef.current);
    }



    if (currentItem) {
      const { dataset } = tooltipsRef.current;
      if (dataset.label !== String(currentItem.label)) {
        dataset.label = String(currentItem.label);


        tooltipsRef.current.innerHTML = `
        <div class="${TOOLTIPS_CLASS_PREFIX}-title">${currentItem.label}</div>
        <ul class="${TOOLTIPS_CLASS_PREFIX}-list">
          ${currentItem.category
            .map(
              (c, i) => `
              <li class="${TOOLTIPS_CLASS_PREFIX}-list-item" style="color: ${colors[i]};">
                <span class="${TOOLTIPS_CLASS_PREFIX}-label">${c.name}:</span>
                <span class="${TOOLTIPS_CLASS_PREFIX}-val">${c.value}</span>
              </li>
            `,
            )
            .join('')}
        </ul>
        `;
      }

      const { scrollWidth } = containerRef.current;
      const { left: containerLeft, top: containerTop } =
        containerRef.current.getBoundingClientRect();
      const { offsetHeight: tooltipsHeight, offsetWidth: tooltipsWidth } = tooltipsRef.current;
      // 浮窗定位(浮窗位置限制不会超出容器范围)
      tooltipsRef.current.setAttribute(
        'style',
        `top: ${Math.max(0, clientY - containerTop - tooltipsHeight - 20)}px; left: ${Math.min(
          scrollWidth - tooltipsWidth,
          Math.max(0, clientX - containerLeft - tooltipsWidth / 2),
        )}px; visibility: visible;`,
      );
    } else {
      handleHiddenTooltips();
    }
  };

  return {
    handleShowTooltips,
    handleHiddenTooltips,
  };
}

useChartTooltipshook要求传入容器元素的ref,和显示的颜色。返回handleShowTooltipshandleHiddenTooltips两个方法。

具体使用如下

function RadarChart({ data, config }: RadarChartProps) {


  // ...






  const containerRef = useRef<HTMLDivElement>(null);
  const { handleHiddenTooltips, handleShowTooltips } = useChartTooltips({
    containerRef,
    colors,
  });




  const handleMouseMove = useThrottle(
    (e: MouseEvent) => {
      const { x, y, clientX, clientY, isWithin } = isWithinOrNotOfPolar(e, {
        centerX,
        centerY,
        radius,
      });
      if (isWithin) {
        // 平分角度
        const angleUnit = (Math.PI * 2) / chartData.length;
        // 判断鼠标位置鼠标位于哪个x轴刻度区域内
        const index = whereIsAreaOfPolar(x, y, centerX, centerY, angleUnit);
        const currentItem = chartData[index];
        showCrosshairs(x, y);
        showDot(currentItem);
        handleShowTooltips(clientX, clientY, currentItem);
      } else {
        hiddenCrosshairs();
        hiddenDot();
        handleHiddenTooltips();
      }
    },
    { wait: 50, trailing: false },
  );



  const handleMouseLeave = () => {
    hiddenCrosshairs();
    handleHiddenTooltips();
  };


  return (
    <div className="rsc-container" ref={containerRef}>
      <svg
        width={width}
        height={height}
        onMouseMove={handleMouseMove.run}
        onMouseLeave={handleMouseLeave}
      >
        {/* x轴 */}
        {xCoordinateAxisNode}
        {/* y轴 */}
        {yCoordinateAxisNode}
        {/* 数据 */}
        {polygonNode}
        {/* 辅助线 */}
        <g ref={crosshairsRef} />
        {/* 辅助点 */}
        <g ref={dotRef} />
      </svg>
    </div>
  );
}

最后

感谢您能坚持看到最后,希望对你有所收获,图表功能还有更多,比如坐标轴可以改成多边形、自定义颜色、自适应宽高等等配置。有兴趣可以前往源代码查看 react-svg-charts 。

完整展示

radar.gif

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYC5ueZO' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片