最近做小程序有一个制作雷达图的需求,本来想直接用一个现成的库就可以了,搜索了下,echarts 能满足需求。但 charts 体积较大,仅仅为了画一个五角的雷达图就增加大几百 k 的资源,似乎有点浪费,于是搜索了下怎么画雷达图。参考文章juejin.cn/post/684490… ,试了下,发现太过复杂,于是决定自己用canvas画。
首先要画正多边形,只要能够计算出正多边形的坐标就行了。正多边形通过圆形就很容易画出来,对于 n 条边的正多边形,只需要按弧度分成 n 等分,然后连接等分的交点就可以了,如下图:
正五边形,分为 5 等分,等分角度 Θ 为 0、72、144、216、288 度,连接等分点 A、B、C、D、E 正五边形就画出来了。
因此不难推出在 canvas 坐标系下,正多边形各顶点的坐标:
/*
centerX,centerY圆心位置
radius: 半径
angle: 等分角度(0、72、144、216、288)
*/
const x = centerX + radius * Math.sin(angle);
const y = centerY - radius * Math.cos(angle);
因此画出正多边形就比较简单了:
//helpers.ts
interface Point {
x: number;
y: number;
}
interface Item {
titleList: string[];
score: number;
fullScore: number;
}
const calcRadius = (edgeCount: number, edgeLength: number) => {
const radius = edgeLength / (2 * Math.sin(Math.PI / edgeCount));
return radius;
};
const calcVertexPositions = (
radius: number,
edgeCount: number,
centerX: number = 0,
centerY: number = 0
) => {
const points: Point[] = [];
const mAngle = (2 * Math.PI) / edgeCount;
for (let i = 0; i < edgeCount; i++) {
const angle = mAngle * i;
const x = centerX + radius * Math.sin(angle);
const y = centerY - radius * Math.cos(angle);
points.push({ x, y });
}
return points;
};
//画正多边形
export const drawPolygon = (
ctx: CanvasRenderingContext2D,
polygonInfo: { radius: number; edgeCount: number }
) => {
const { radius, edgeCount } = polygonInfo;
const draw = (points: Point[]) => {
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < edgeCount; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.strokeStyle = '#3388FF';
ctx.stroke();
};
const points = calcVertexPositions(radius, edgeCount);
draw(points);
return points;
};
//画嵌套的多个正多边形
const drawPolygons = (
ctx: CanvasRenderingContext2D,
radarMapInfo: {
polygonCount: number;
edgeCount: number;
edgeLength: number;
}
) => {
const { polygonCount, edgeCount, edgeLength } = radarMapInfo;
const drawDiagonals = (points: Point[]) => {
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
ctx.moveTo(0, 0);
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.stroke();
};
const radius = calcRadius(edgeCount, edgeLength);
const r = radius / edgeCount;
for (let i = 0; i < polygonCount; i++) {
const currentRadius = r * (i + 1);
const points = drawPolygon(ctx, {
radius: currentRadius,
edgeCount,
});
if (i === polygonCount - 1) {
//画最外层多边形对角线
drawDiagonals(points);
return points;
}
}
};
//画雷达图
function drawRadar(
ctx: CanvasRenderingContext2D,
edgeCount: number,
edgeLength: number,
items: Item[]
) {
const radius = calcRadius(edgeCount, edgeLength);
const points = calcVertexPositions(radius, edgeCount);
const radarVertex = items.map(({ score, fullScore }, index) => {
const { x, y } = points[index];
const ratio = score / fullScore;
return { x: x * ratio, y: y * ratio };
});
ctx.beginPath();
for (let i = 0; i < edgeCount; i++) {
i === 0
? ctx.moveTo(radarVertex[i].x, radarVertex[i].y)
: ctx.lineTo(radarVertex[i].x, radarVertex[i].y);
}
ctx.fillStyle = 'rgba(204,0,0,0.3)';
ctx.strokeStyle = 'red';
ctx.lineWidth = 3;
ctx.closePath();
ctx.stroke();
ctx.fill();
}
export const drawRadarMap = async ({
dpr,
canvasID,
edgeLength,
polygonCount,
items,
canvasWidth,
canvasHeight,
}: {
dpr: number;
canvasID: string;
edgeLength: number;
polygonCount: number;
canvasWidth: number;
canvasHeight: number;
items: Item[];
}) => {
const canvas = await getElementByID(canvasID);
//解决模糊问题
canvas.width = canvasWidth * dpr;
canvas.height = canvasHeight * dpr;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(dpr, dpr);
const edgeCount = items.length;
drawPolygons(ctx, {
polygonCount,
edgeCount,
edgeLength,
});
drawRadar(ctx, edgeCount, edgeLength, items);
};
以上就能画出雷达图了,不管是 h5 还是 taro,高版本的小程序 canvas api 基本和 web 端一致了,taro 里只是获取 canvas 实例的方式有些不一样:
import Taro from '@tarojs/taro';
export const getElementByID = (id: string): Promise<Taro.Canvas> => {
return new Promise((reslove, reject) => {
const query = Taro.createSelectorQuery();
query
.select(`#${id}`)
.node()
.exec((res) => {
if (res && res[0]?.node) {
reslove(res[0].node);
} else {
reject(res);
}
});
});
};
需要注意的是上述方法在 taro 中不一定能拿到实例,有几点需要满足:
- Canvas 必须添加 type
- Canvas 的 id 属性不要用 canvasId 而是用 id
- 需要在 useReady 中的 nextTick 执行 getElementByID
import Taro, { useReady } from '@tarojs/taro';
import { styled } from 'linaria/react';
import { useMemo } from 'react';
import { Canvas } from '@tarojs/components';
import { drawRadarMap } from './helpers';
const CANVAS_WIDTH = 320;
const CANVAS_HEIGHT = 200;
export const StyledCanvas = styled(Canvas)`
width: ${CANVAS_WIDTH}Px;
height: ${CANVAS_HEIGHT}Px;
`;
// 雷达图数据
const items = [
{ titleList: ['以父之名', '周杰伦'], score: 3, fullScore: 5 },
{ titleList: ['爱在西元前'], score: 5, fullScore: 10 },
{ titleList: ['简单爱'], score: 5, fullScore: 10 },
{ titleList: ['夜曲一响', '上台领奖'], score: 15, fullScore: 15 },
{ titleList: ['无与伦比'], score: 10, fullScore: 20 },
];
const Radar = () => {
const dpr = useMemo(() => {
return Taro.getSystemInfoSync().pixelRatio;
}, []);
useReady(() => {
Taro.nextTick(() => {
drawRadarMap({
canvasID: 'radar',
edgeLength: 80,
polygonCount: 5,
items,
dpr,
canvasWidth: CANVAS_WIDTH,
canvasHeight: CANVAS_HEIGHT,
});
});
});
return <StyledCanvas type="2d" id="radar" />;
};
export default Radar;
效果图: