前沿
在上一篇 # 轻松实现!用SVG打造简洁实用的柱形图和折线图,让数据一目了然 中,我们介绍了使用svg柱形图和折线图的制作。不要停下脚步,这次我们将介绍雷达图。
雷达图是一种基于极坐标系的数据可视化图表,通过多边形和直线的组合呈现出数据在不同维度上的分布情况,具有直观性和可比性。在数据报告、市场调研、竞争分析等领域中,雷达图是一个非常重要的数据呈现方式。
展示
先看完整的效果展示,也可以前往 zeng-j.github.io/react-svg-c… 查看。
开始
话不多说,我们行动。封装图表,我们先约定一些图表的配置数据。
属性 | 说明 | 值 |
---|---|---|
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);
结合图例来看下计算值的意思
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>
);
}
显示如下
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本身的坐标系)。
先给出图例
就是说,如果已知角度、圆心、半径,就能计算坐标点的位置。我们来封装一下函数。我们以圆心为原点再模拟一个直角坐标系,角度以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>
);
}
展示如下
数据展示
数据展示也是同样要计算每个点的在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>
);
}
展示效果如下。
雷达图基本绘制就算完成啦!!!
hover辅助线
下面我们来添加一些交互,让交互体验更加友好。
先看展示效果
思路很简单,就是给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辅助点,这个就麻烦一点,因为需要计算鼠标位于哪个刻度线范围。
先看下效果
思考下,我们知道鼠标在svg元素内的坐标位置,我们要怎么计算它位于哪个刻度区域内呢?
我们回顾下数学知识中的三角函数之正切函数。它的反函数为反正切函数————y = arctan(x)
,值域为(-π/2,π/2)
。
就是说我们知道坐标点,我们就能知道坐标点到圆心 与 x正向轴的角度,但是范围为(-π/2,π/2)
。
但是我们需要知道360度的角度才能够完整判断怎么办?
还好 Javascript 提供了 Math.atan2()
函数。Math.atan2()
返回从原点 (0,0) 到 (x,y) 点的线段与 x 轴正方向之间的平面角度 (弧度值),就是 Math.atan2(y,x)
(注意传入的是先y后x)。范围为(-π,π)
。
我们需要把(-π,π)
变成(0,2π)
。例如下图所示
封装成函数就是如下
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轴反转一下。如下图所示。
也是说一个坐标点 (x, y),正常使用calcAngleByCoordinate(x, y)
,我们现在把两个参数调换calcAngleByCoordinate(y, x)
即可。
现在已知坐标点,我们能够计算出角度了。
稍等,还还还有最后一个问题,先看下图。
鼠标在第一个刻度区域范围内如上面左图显示,第二个刻度区域及后续的以此类推。而我们上面计算的角度是正 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,
};
}
该useChartTooltips
hook要求传入容器元素的ref,和显示的颜色。返回handleShowTooltips
和handleHiddenTooltips
两个方法。
具体使用如下
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 。
完整展示