Skip to content

雷达图

展示

数据1:
36
数据2:
3
数据3:
7
数据4:
80
数据5:
58
调用代码 点击展开
vue
<template>
  <div class="demo">
    <div class="controls">
      <div class="btn-box">
        <el-button @click="add">新增</el-button>
        <el-button @click="remove">删除</el-button>
      </div>
      <div class="item" v-for="score in scores" :key="score[0]">
        <span>{{ score[0] }}:</span>
        <el-slider class="slider" :min="0" :max="maxScore" v-model="score[1]" />
        <span>{{ score[1] }}</span>
      </div>
    </div>
    <PolygonScore
      :size="size"
      :maxScore="maxScore"
      :scores="scores"
    ></PolygonScore>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import PolygonScore from "./PolygonScore.vue";
import { PolygonScores } from "./PolygonScoreTypes";
const size = ref(300);
const maxScore = ref(100);
const scores = ref<PolygonScores>([]);
for (let i = 0; i < 5; i++) {
  scores.value.push([
    `数据${i + 1}`,
    Math.floor(Math.random() * maxScore.value),
  ]);
}
function add() {
  scores.value.push([
    `数据${scores.value.length + 1}`,
    Math.floor(Math.random() * maxScore.value),
  ]);
}
function remove() {
  scores.value.pop();
}
</script>

<style scoped lang="scss">
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  row-gap: 20px;
}
.controls {
  display: flex;
  flex-direction: column;
  align-items: start;
  row-gap: 10px;
  width: 300px;
  .item {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: space-between;
    .slider {
      width: 180px;
    }
  }
}
.btn-box {
  display: flex;
  width: 100%;
  justify-content: center;
}
</style>
组件代码 点击展开
vue
<template>
  <canvas
    ref="canvasRef"
    :width="size"
    :height="size"
    :style="{
      width: `${size}px`,
      height: `${size}px`,
    }"
  ></canvas>
</template>

<script setup lang="ts">
import { ref, watchEffect, onUnmounted } from "vue";
import { PropsType } from "./PolygonScoreTypes";
import { draw } from "./PolygonScoreHooks";
const canvasRef = ref<HTMLCanvasElement | null>(null);
const props = withDefaults(defineProps<PropsType>(), {
  size: 300,
  maxScore: 100,
});
const stop = watchEffect(() => {
  if (!canvasRef.value) {
    return;
  }
  draw(canvasRef.value, props);
});
onUnmounted(stop);
</script>

<style scoped></style>
JS 控制代码 点击展开
ts
import { PropsType } from './PolygonScoreTypes';


function useConfig(ctx: CanvasRenderingContext2D, props: Required<PropsType>) {
  // 设置字体类型和大小
  const fontSize = 16;
  const fontFamily = 'PingFangSC-Regular, sans-serif';
  ctx.font = `${fontSize}px ${fontFamily}`;
  // 多行文字间隙
  const vGap = fontSize * 0.5; // 垂直间隙
  // 获取最长文字在画布中的宽度
  const maxTextWidth = props.scores.reduce((max, [text, score]) => {
    const textWidth = ctx.measureText(text).width;
    const scoreWidth = ctx.measureText(score.toString()).width;
    return Math.max(max, textWidth, scoreWidth);
  }, 0);
  // 获取多行文字的最大高度
  const maxTextHeight = fontSize * 2 + vGap;
  // 获取需要预留的文字空间
  const textSpace = Math.max(maxTextWidth, maxTextHeight);
  // 文字与内圆的间距
  const textPadding = 8;
  // 得到圆的半径
  const radius = ctx.canvas.width / 2 - textSpace - textPadding;
  // 线
  const lineWidth = 1;
  const lineColor = '#bdc4fc';
  // 背景
  const bgColor = '#a8b1ff';
  return {
    fontSize,
    fontFamily,
    radius,
    textPadding,
    lineWidth,
    lineColor,
    bgColor,
    vGap,
    maxTextHeight,
  };
}

function drawHelper(
  radius: number,
  sides: number,
  callback: (x: number, y: number, i: number) => void
) {
  const angle = (2 * Math.PI) / sides;
  for (let i = 0; i < sides; i++) {
    const x = radius * Math.sin(angle * i);
    const y = -radius * Math.cos(angle * i);
    callback(x, y, i);
  }
}

function drawPolygon(
  ctx: CanvasRenderingContext2D,
  props: Required<PropsType>
) {
  const config = useConfig(ctx, props);
  const { width, height } = ctx.canvas;
  // 初始化画布
  ctx.translate(width / 2, height / 2);
  ctx.strokeStyle = config.lineColor;
  ctx.lineWidth = config.lineWidth;
  ctx.fillStyle = config.bgColor;
  ctx.font = `${config.fontSize}px ${config.fontFamily}`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'top';
  const { scores } = props;
  const sides = scores.length;

  // 画内部多边形
  ctx.beginPath();
  drawHelper(config.radius, sides, (x, y, i) => {
    const [_, score] = scores[i];
    const percent = score / props.maxScore;
    x = x * percent;
    y = y * percent;
    ctx.lineTo(x, y);
  });
  ctx.fill();

  // 画多边形外圈
  ctx.beginPath();
  ctx.moveTo(0, -config.radius);
  drawHelper(config.radius, sides, (x, y) => {
    ctx.lineTo(x, y);
  });
  ctx.closePath();
  ctx.stroke();

  // 画连线
  drawHelper(config.radius, sides, (x, y) => {
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(x, y);
    ctx.stroke();
  });

  // 画文字
  drawHelper(config.radius + config.textPadding, sides, (x, y, i) => {
    const [text, score] = scores[i];
    const textWidth = ctx.measureText(text).width;
    const scoreWidth = ctx.measureText(score.toString()).width;
    const maxWidth = Math.max(textWidth, scoreWidth);
    const vGap = config.vGap; // 垂直间隙
    let firstLineX = x,
      firstLineY = y;
    let secondLineX = x,
      secondLineY = y;
    // 定义允许的误差
    const allowError = 0.1;
    if (Math.abs(x) < allowError) {
      firstLineX = x;
    } else if (x > 0) {
      firstLineX = secondLineX = x + maxWidth / 2;
    } else if (x < 0) {
      firstLineX = secondLineX = x - maxWidth / 2;
    }
    secondLineX = firstLineX;
    if (Math.abs(y) < allowError) {
      firstLineY = y - config.fontSize - vGap / 2;
    } else if (y > 0) {
      firstLineY = y;
    } else {
      firstLineY = y - config.fontSize * 2 - vGap;
    }
    secondLineY = firstLineY + config.fontSize + vGap;

    ctx.fillText(text, firstLineX, firstLineY);
    ctx.fillText(score.toString(), secondLineX, secondLineY);
  });
}

export function draw(cvs: HTMLCanvasElement, props: Required<PropsType>) {
  const ctx = cvs.getContext('2d')!;
  const { width, height } = cvs;
  ctx.save();
  // 清空画布
  ctx.clearRect(0, 0, width, height);
  // 画
  drawPolygon(ctx, props);
  ctx.restore();
}
TS 类型 点击展开
ts
export type Score = [string, number];
export type PolygonScores = Score[];
export interface PropsType {
  size?: number;
  scores: PolygonScores;
  maxScore?: number;
}

视频讲解