taro canvas绘制雷达图

1,056 阅读2分钟

最近做小程序有一个制作雷达图的需求,本来想直接用一个现成的库就可以了,搜索了下,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;

效果图: