如何 用canvas 绘制左心肌牛眼图 (左心室心肌)

484 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

美国心脏协会(AHA)发表了左心室心肌的分割和命名法,即心脏分割模型,现在医学界广泛使用该模型描述疾病对心肌区域和壁功能的影响。该模型图类似靶心图、牛眼图、环形图、bullseye chart.

image.png

  1. 首先确定数据格式,一般数据格式会是
    // 第一层为 外圈 中圈 内圈
    // 第二层为每个大圈里有多少细分的小圈
    [
        [
            [],
            ...
        ],
        [] , 
        [],
    ]
    
  2. 图形可以看成是由多个扇形组成上面的扇形覆盖下面的扇形从而组成这个圆

image.png

也可以是由上半部分的半扇形组成(下面用上面半扇形来画,少画一些减少浏览器的渲染压力) image.png

  1. 成品

image.png 更复杂的数据 image.png

  1. 代码
class BullseyeCanvas {
  constructor(
    data = [],
    meanArr = [],
    radius = 100,
    colorBar= { 
      max: 20, 
      min: 0, 
      colorList: [
        'rgba(255,0,0)',
        'rgba(255,255,0)',
        'rgba(0,255,0)', 
        'rgba(0,255,255)', 
        'rgba(0,0,255)'
      ] 
    }
  ) {
    this.border = 1;
    this.borderWidth = `${this.border}px`;
    this.borderColor = 'rgba(109,114,120,0.70)';
    this.centerColor = '#000';
    this.fontSize = '10';
    this.font = `${this.fontSize}px`;
    this.fontColor = '#fff';
    // 半径
    this.radius = radius - 2 * this.border;
    this.centerX = radius;
    this.centerY = radius;
    // 画底图需要的数据
    this.data = data;
    // 画 text 需要的数据
    this.meanArr = meanArr;
    this.canvas = document.createElement('canvas');
    this.canvas.width = 2 * radius;
    this.canvas.height = 2 * radius;
    // 画布
    this.ctx = this.canvas.getContext('2d');
    // 创建colorBar
    this.max = colorBar.max;
    this.min = colorBar.min;
    this.colorList = colorBar.colorList;
    this.colorBarHeight = 100;
    this.colorList = colorBar.colorList;
    const colorBarObj = this.createColorBar(colorBar.colorList);
    this.colorBarCtx = colorBarObj.ctx;
    this.colorBarCanvas = colorBarObj.canvas;


    this.radiusArr = this.getRadiusArr(radius);
    this.borderRadius = this.radiusArr.slice(0, this.radiusArr.length - 1);
  }

  getRadiusArr(radius) {
    return [
      radius,
      radius * 0.7,
      radius * 0.4,
      radius * 0.1,
    ];
  }

  createColorBar(colorList) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    const width = 2;
    ctx.save();
    ctx.clearRect(0, 0, width, this.colorBarHeight);
    const grd = ctx.createLinearGradient(0, 0, width, this.colorBarHeight);
    const len = colorList.length;
    if (len >= 2) {
      grd.addColorStop(0, colorList[0]);
      const count = len - 2;
      for (let index = 0; index < count; index++) {
        grd.addColorStop((index + 1) / (count + 1), colorList[index + 1]);
      }
      grd.addColorStop(1, colorList[len - 1]);
    }
    ctx.fillStyle = grd;
    ctx.fillRect(0, 0, width, this.colorBarHeight);
    return {ctx,canvas}
  }
  // 获取颜色
  getColor = (num) => {
    const { colorBarCtx: ctx, max, min, colorList, colorBarHeight } = this;
    if (!ctx) return '#404040';
    let color = '';
    if (num == null) {
      color = '#404040';
    } else if (num >= max) {
      color = colorList[0];
    } else if (num <= min) {
      color = colorList[colorList.length - 1];
    } else {
      const offsetY = (colorBarHeight / (max - min)) * (max - num);
      const rgbaData = ctx.getImageData(
        1,
        offsetY,
        1,
        1,
      ).data;
      color = `rgba(${rgbaData[0]},${rgbaData[1]},${rgbaData[2]},${rgbaData[3] / 255})`;
    }
    return color;
  };

  /**
   * @description:
   * @param {*} count 第几个块
   * @param {*} len 均分圆多少份
   * @param {*} originAngle 开始画的角度
   * @return {*} { startAngle: '', endAngle: '', theta: '' }
   */
  getAngle(count, len, originAngle) {
    const theta = (1 / len) * 2 * Math.PI;
    const startAngle = originAngle - count * theta;
    const endAngle = startAngle - theta;
    return { startAngle, endAngle, theta };
  }

  /** 
   * @description: 画扇形
   * @param {*} radius 大的半径
   * @param {*} nextRadius 下一个小的半径
   * @param {*} startAngle 开始画的角度
   * @param {*} endAngle 结束画的角度
   * @param {*} num 当前值 (是否需要填充和获取值映射的颜色)
   * @return {*}
   */  
  drawSector(radius, nextRadius, startAngle, endAngle) {
    const { ctx, centerX, centerY } = this;
    ctx.beginPath(); // 开始绘制
    // 如果是顺时针需要 将后面的参数 true 和 false 调一下
    ctx.arc(centerX, centerY, radius, startAngle, endAngle, true);
    ctx.arc(centerX, centerY, nextRadius, endAngle, startAngle, false);
    ctx.closePath(); // 结束绘制 将绘制结束点和绘制起始点连接
  }

  // 画中心圆 
  drawCircular(radius) {
    const { ctx, centerX, centerY } = this;
    ctx.save();
    ctx.beginPath();
    ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
    ctx.closePath();
    ctx.fillStyle = this.centerColor;
    ctx.fill();
    ctx.restore();
  }

  drawCanvas() {
    const { data, radiusArr, ctx } = this;
    if (!Array.isArray(data)) {
      console.error('data is not Array');
    }
    // 循环大圈
    data.forEach((circular, count) => {
      // 大圈的总长度
      const radiusSubtracting = this.radiusArr[count] - this.radiusArr[count + 1];
      // 初始大圈 半径
      const originRadius = radiusArr[count];
      // 每个 大圈 细分多少小圈
      const circularLen = circular.length;
      // 每个小圈半径 长度
      const thetaRadius = radiusSubtracting / circularLen;
      // 循环大圈 中细分小圈
      circular.forEach((block, i) => {
        // 圈起始 画的角度 现在逆时针
        const originAngle = 2 * Math.PI * (count === 2 ? (- 1 / 8) : (- 1 / 6))
        // 每圈总共需要分多少块
        const blockLen = block.length;
        // 起始半径
        const radius = originRadius - i * thetaRadius;
        // 结束半径
        const nextRadius = radius - thetaRadius;
        // 循环小圈 中细分小块
        block.forEach((num, index) => {
          const { startAngle, endAngle } = this.getAngle(index, blockLen, originAngle);
          this.drawSector(
            radius,
            nextRadius,
            startAngle,
            endAngle
          );
          ctx.fillStyle = this.getColor(num);
          ctx.fill();
        });
      });
    });
  }

  // 塞入 字符
  setData(radius, nextRadius, startAngle, endAngle, value) {
    const {
      ctx, centerX, centerY, fontColor, fontSize, font,
    } = this;
    const midAngle = (startAngle + endAngle) / 2;
    const midRadius = (radius + nextRadius) / 2;
    const xCord = centerX + midRadius * Math.cos(midAngle) - (fontSize * (`${value}`).length) / 4;
    const yCord = centerY + midRadius * Math.sin(midAngle) + fontSize / 2;
    ctx.save();
    ctx.font = font;
    ctx.fillStyle = '#000';
    ctx.fillText(value, xCord - 1, yCord);
    ctx.fillText(value, xCord, yCord - 1);
    ctx.fillText(value, xCord - 1, yCord - 1);
    ctx.fillText(value, xCord + 1, yCord);
    ctx.fillText(value, xCord, yCord + 1);
    ctx.fillText(value, xCord + 1, yCord + 1);
    ctx.fillStyle = fontColor;
    ctx.fillText(value, xCord, yCord);
    ctx.restore();
  }

  /**
   * @param {*} isText true 画上面的框架  / false 均值牛眼图
   * @return {*}
   */
  drawFrame(isText = false) {
    const { radiusArr, ctx } = this;
    if(this.meanArr.length === 0) {
      this.meanArr = Array(16).fill(0).map((_,index) => index)
    }
    const max = this.meanArr.slice(0, 6);
    const middle = this.meanArr.slice(6, 12);
    const min = this.meanArr.slice(12, 16);
    const mean = [max, middle, min];
    ctx.save();

    // let count = 0;
    ctx.strokeWidth = this.borderWidth;
    ctx.strokeStyle = this.borderColor;
    this.drawCircular(radiusArr[radiusArr.length - 1]);

    mean.forEach((circular, i) => {
      const circularLen = circular.length;
      circular.forEach((item, index) => {
        const originAngle = 2 * Math.PI * (i === 2 ? (- 1 / 8) : (- 1 / 6));
        const { startAngle, endAngle } = this.getAngle(index, circularLen, originAngle);
        this.drawSector(radiusArr[i], radiusArr[i + 1], startAngle, endAngle);

        if (isText) {
          this.setData(radiusArr[i], radiusArr[i + 1], startAngle, endAngle, item);
        }else{
          ctx.strokeStyle = this.borderColor;
          ctx.stroke();
        }
      });
    });
    ctx.restore();
  }

  draw() {
    // 先画底图
    this.drawCanvas();
    // 再画 上面的边框
    this.drawFrame(false);
    // 再写 label
    this.drawFrame(true);
    return this.canvas;
  }
}

代码还能优化,欢迎大家指出意见