开饭啦!恰个3D饼图

简介: 用Three.js搞个3D饼图

用Three.js搞个3D饼图

1.准备工作

(1)渐变颜色

/**
 * 获取暗色向渐变颜色
 * @param {string} color 基础颜色
 * @param {number} step  数量
 * @returns {array} list 颜色数组
 */
export function getShadowColor(color, step) {
   
   
  let c = getColor(color);
  let {
   
    red, blue, green } = c;

  const s = 0.8;
  const r = parseInt(red * s),
    g = parseInt(green * s),
    b = parseInt(blue * s);

   //获取亮色向渐变颜色 
 // const l = 0.2;
  //const r = red + parseInt((255 - red) * l),
 //   g = green + parseInt((255 - green) * l),
 //   b = blue + parseInt((255 - blue) * l);


  const rr = (r - red) / step,
    gg = (g - green) / step,
    bb = (b - blue) / step;

  let list = [];
  for (let i = 0; i < step; i++) {
   
   
    list.push(
      `rgb(${parseInt(red + i * rr)},${parseInt(green + i * gg)},${
     
     parseInt(blue + i * bb)})`
    );
  }
  return list;
}

(2)生成文本的canvas精灵贴图

生成文本canvas

/**
 * 生成文本canvas
 * @param {array} textList [{text:文本,color:文本颜色}]
 * @param {number} fontSize 字体大小
 * @returns
 */
export function getCanvasTextArray(textList, fontSize) {
   
   
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.font = fontSize + 'px Arial';
  //计算canvas宽度
  let textLen = 0;
  textList.forEach((item) => {
   
   
    let w = ctx.measureText(item.text + '').width;
    if (w > textLen) {
   
   
      textLen = w;
    }
  });
  canvas.width = textLen;
  canvas.height = fontSize * 1.2 * textList.length;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.font = fontSize + 'px Arial';
  //行高是1.2倍
  textList.forEach((item, idx) => {
   
   
    ctx.fillStyle = item.color;
    ctx.fillText(item.text, 0, fontSize * 1.2 * idx + fontSize);
  });

  return canvas;
}

生成文本精灵材质


/**
 *生成文本精灵材质
 * @param {THREE.js} THREE
 * @param {array} textlist 文本颜色数组
 * @param {number} fontSize 字体大小
 * @returns
 */
export function getTextArraySprite(THREE, textlist, fontSize) {
   
   
  //生成五倍大小的canvas贴图,避免大小问题出现显示模糊
  const canvas = getCanvasTextArray(textlist, fontSize * 5);
  const map = new THREE.CanvasTexture(canvas);
  map.wrapS = THREE.RepeatWrapping;
  map.wrapT = THREE.RepeatWrapping;

  const material = new THREE.SpriteMaterial({
   
   
    map: map,
    depthTest: false,
    side: THREE.DoubleSide
  });
  const mesh = new THREE.Sprite(material);
  //缩小等比缩小canvas精灵贴图
  mesh.scale.set(canvas.width * 0.1, canvas.height * 0.1);
  return {
   
    mesh, material, canvas };
}

注意:canvas贴图一定要是原始大小的几倍,否则会因为贴图大小导致显示模糊的情况

(3)扇形柱体弧度与角度计算

  • 360度是全部的值加起来的结果,那么每项的值占的角度为
    let angel = (item.value / sum) * Math.PI * 2
    
  • 同理,值与高度映射,基础高度加上映射高度
    let allHeight = maxHeight - baseHeight;
    let h = baseHeight + ((item.value - min) / valLen) * allHeight;
    

2.画大饼

createChart(that) {
   
   
          this.that = that;
          if (this.group) {
   
   
            this.cleanObj(this.group);
            this.group = null;
          }
          if (that.data.length == 0) {
   
   
            return;
          }
          this.cLen = 3;
          //从小到大排序
          that.data = that.data.sort((a, b) => a.value - b.value);
          //获取渐变色
          this.colors = getDrawColors(that.colors, this.cLen);

          let {
   
    baseHeight, radius, perHeight, maxHeight, fontColor, fontSize } = that;

          let sum = 0;
          let min = Number.MAX_SAFE_INTEGER;
          let max = 0;
          for (let i = 0; i < that.data.length; i++) {
   
   
            let item = that.data[i];
            sum += item.value;
            if (min > item.value) {
   
   
              min = item.value;
            }
            if (max < item.value) {
   
   
              max = item.value;
            }
          }

          let startRadius = 0;
          let valLen = max - min;
          let allHeight = maxHeight - baseHeight;
          let axis = new THREE.Vector3(1, 0, 0);
          let group = new THREE.Group();
          this.group = group;
          this.scene.add(group);

          for (let idx = 0; idx < that.data.length; idx++) {
   
   
            let objGroup = new THREE.Group();
            objGroup.name = 'group' + idx;
            let item = that.data[idx];
            //角度范围
            let angel = (item.value / sum) * Math.PI * 2;
            //高度与值的映射
            let h = baseHeight + ((item.value - min) / valLen) * allHeight;
            //每个3D组成块组成:扇形柱体加两片矩形面
            if (item.value) {
   
   
            //创建渐变色材质组
              let cs = this.colors[idx % this.colors.length];

              let geometry = new THREE.CylinderGeometry(
                radius,
                radius,
                h,
                24,
                24,
                false,
                startRadius, //开始角度
                angel //扇形角度占有范围
              );

              let ms = [];
              for (let k = 0; k < this.cLen - 1; k++) {
   
   
                ms.push(getBasicMaterial(THREE, cs[k]));
              }
              //给不同面的设定对应的材质索引
              geometry.faces.forEach((f, fIdx) => {
   
   
                if (f.normal.y == 0) {
   
   
                  //上面和底面
                  geometry.faces[fIdx].materialIndex = 0;
                } else {
   
   
                  //侧面
                  geometry.faces[fIdx].materialIndex = 1;
                }
              });
              //扇形圆柱
              let mesh = new THREE.Mesh(geometry, ms);
              mesh.position.y = h * 0.5;
              mesh.name = 'p' + idx;
              objGroup.add(mesh);

              const g = new THREE.PlaneGeometry(radius, h);
              let m = getBasicMaterial(THREE, cs[this.cLen - 1]);

              //注意图形开始角度和常用的旋转角度差90度

              //封口矩形1
              let r1 = startRadius + Math.PI * 0.5;
              const plane = new THREE.Mesh(g, m);
              plane.position.y = h * 0.5;
              plane.position.x = 0;
              plane.position.z = 0;
              plane.name = 'c' + idx;
              plane.rotation.y = r1;
              plane.translateOnAxis(axis, -radius * 0.5);
              objGroup.add(plane);
              //封口矩形2
              let r2 = startRadius + angel + Math.PI * 0.5;
              const plane1 = new THREE.Mesh(g, m);
              plane1.position.y = h * 0.5;
              plane1.position.x = 0;
              plane1.position.z = 0;
              plane1.name = 'b' + idx;
              plane1.rotation.y = r2;
              plane1.translateOnAxis(axis, -radius * 0.5);
              objGroup.add(plane1);

              //显示label
              if (that.isLabel) {
   
   
                let textList = [
                  {
   
    text: item.name, color: fontColor },
                  {
   
    text: item.value + that.suffix, color: fontColor }
                ];

                const {
   
    mesh: textMesh } = getTextArraySprite(THREE, textList, fontSize);
                textMesh.name = 'f' + idx;
                //y轴位置
                textMesh.position.y = maxHeight + baseHeight;
                //x,y轴位置
                let r = startRadius + angel * 0.5 + Math.PI * 0.5;
                textMesh.position.x = -Math.cos(r) * radius;
                textMesh.position.z = Math.sin(r) * radius;
                //是否开启动画
                if (this.that.isAnimate) {
   
   
                  if (idx == 0) {
   
   
                    textMesh.visible = true;
                  } else {
   
   
                    textMesh.visible = false;
                  }
                }

                objGroup.add(textMesh);
              }
              group.add(objGroup);
            }
            startRadius = angel + startRadius;
          }
    //图形居中,视角设置
          this.setModelCenter(group, that.viewControl);
        }

        animateAction() {
   
   
          if (this.that?.isAnimate && this.group) {
   
   
            this.time++;
            this.rotateAngle += 0.01;
            //物体自旋转
            this.group.rotation.y = this.rotateAngle;
            //标签显隐切换
            if (this.time > 90) {
   
   
              if (this.currentTextMesh) {
   
   
                this.currentTextMesh.visible = false;
              }
              let textMesh = this.scene.getObjectByName('f' + (this.count % this.that.data.length));
              textMesh.visible = true;
              this.currentTextMesh = textMesh;
              this.count++;
              this.time = 0;
            }
            if (this.rotateAngle > Math.PI * 2) {
   
   
              this.rotateAngle = 0;
            }
          }
        }
      }

注意:

  • (1)图形开始角度和常用的旋转角度差90度

  • (2)这里使用了多材质,扇形柱体存在多个面,需要给不同面的设定对应的材质索引,这里直接根据法线y值判断

geometry.faces.forEach((f, fIdx) => {
   
   
                if (f.normal.y == 0) {
   
   
                  //上面和底面
                  geometry.faces[fIdx].materialIndex = 0;
                } else {
   
   
                  //侧面
                  geometry.faces[fIdx].materialIndex = 1;
                }
              });
  • (3)这里不使用FontLoader来加载生成字体mesh,因为如果要涵盖所有字,字体资源包贼大,为了渲染几个字加载那么大的东西,不值得!这里文本采用的canvas生成贴图,创建精灵材质,轻量普适性高!
  • (4)我这里没有采用光照实现不同面的颜色偏差,而是采用渐变色,因为光照会导致颜色偏差较大,呈现出的颜色有可能不符合UI设计的效果。

3.使用3D饼图

 let cakeChart = new Cake();
      cakeChart.initThree(document.getElementById('cake'));
      cakeChart.createChart({
   
   
        //颜色
        colors: ['#fcc02a', '#f16b91', '#187bac'],
        //数据
        data: [
          {
   
    name: '小学', value: 100 },
          {
   
    name: '中学', value: 200 },
          {
   
    name: '大学', value: 300 }
        ],
        //是否显示标签
        isLabel: true,
        //最大高度
        maxHeight: 20,
        //基础高度
        baseHeight: 10,
        //半径
        radius: 30,
        //单位后缀
        suffix: '',
        //字体大小
        fontSize: 10,
        //字体颜色
        fontColor: 'rgba(255,255,255,1)',
        //开启动画
        isAnimate: true,
        //视角控制
        viewControl: {
   
   
          autoCamera: true,
          width: 1,
          height: 1.6,
          depth: 1,
          centerX: 1,
          centerY: 1,
          centerZ: 1
        }
      });
      //缩放时调整大小
      window.onresize = () => {
   
   
        cakeChart.onResize();
      };
      //离开时情况资源
      window.onunload = () => {
   
   
        cakeChart.cleanAll();
      };
  • 开启动画

20230619_162038.gif

  • 不开启动画时

20230619_162728.gif

4.环状3D饼图

  • 拆分成六个面,内外圈环面,上下环面,两个侧面

    //外圈
                let geometry = new THREE.CylinderGeometry(
                  outerRadius,
                  outerRadius,
                  h,
                  24,
                  24,
                  true,
                  startRadius,
                  angel
                );
                let mesh = new THREE.Mesh(geometry, getBasicMaterial(THREE, cs[1]));
                mesh.position.y = h * 0.5;
                mesh.name = 'p' + idx;
                objGroup.add(mesh);
                //内圈
                let geometry1 = new THREE.CylinderGeometry(
                  innerRadius,
                  innerRadius,
                  h,
                  24,
                  24,
                  true,
                  startRadius,
                  angel
                );
                let mesh1 = new THREE.Mesh(geometry1, getBasicMaterial(THREE, cs[2]));
                mesh1.position.y = h * 0.5;
                mesh1.name = 'pp' + idx;
                objGroup.add(mesh1);
    
                let geometry2 = new THREE.RingGeometry(
                  innerRadius,
                  outerRadius,
                  32,
                  1,
                  startRadius,
                  angel
                );
                //上盖
                let mesh2 = new THREE.Mesh(geometry2, getBasicMaterial(THREE, cs[0]));
                mesh2.name = 'up' + idx;
                mesh2.rotateX(-0.5 * Math.PI);
                mesh2.rotateZ(-0.5 * Math.PI);
                mesh2.position.y = h;
                objGroup.add(mesh2);
                //下盖
                let mesh3 = new THREE.Mesh(geometry2, getBasicMaterial(THREE, cs[3]));
                mesh3.name = 'down' + idx;
                mesh3.rotateX(-0.5 * Math.PI);
                mesh3.rotateZ(-0.5 * Math.PI);
                mesh3.position.y = 0;
                objGroup.add(mesh3);
    
                let m = getBasicMaterial(THREE, cs[4]);
    
                //侧面1
                const g = new THREE.PlaneGeometry(ra, h);
                const plane = new THREE.Mesh(g, m);
                plane.position.y = h * 0.5;
                plane.position.x = 0;
                plane.position.z = 0;
                plane.name = 'c' + idx;
                plane.rotation.y = startRadius + Math.PI * 0.5;
                plane.translateOnAxis(axis, -(innerRadius + 0.5 * ra));
                objGroup.add(plane);
                //侧面2
                const plane1 = new THREE.Mesh(g, m);
                plane1.position.y = h * 0.5;
                plane1.position.x = 0;
                plane1.position.z = 0;
                plane1.name = 'b' + idx;
                plane1.rotation.y = startRadius + angel + Math.PI * 0.5;
                plane1.translateOnAxis(axis, -(innerRadius + 0.5 * ra));
                objGroup.add(plane1);
    
  • 使用3D环状饼图
 let cakeChart = new Cake();
      cakeChart.initThree(document.getElementById('cake'));
      cakeChart.createChart({
   
   
        //颜色
        colors: ['#fcc02a', '#f16b91', '#187bac'],
        //数据
        data: [
          {
   
    name: '小学', value: 100 },
          {
   
    name: '中学', value: 200 },
          {
   
    name: '大学', value: 300 }
        ],
        //是否显示标签
        isLabel: true,
        //最大高度
        maxHeight: 20,
        //基础高度
        baseHeight: 10,
        //外半径
        outerRadius: 30,
        //内半径
        innerRadius: 15,
        //单位后缀
        suffix: '',
        //字体大小
        fontSize: 10,
        //字体颜色
        fontColor: 'rgba(255,255,255,1)',
        //开启动画
        isAnimate: false,
        //视角控制
        viewControl: {
   
   
          autoCamera: true,
          width: 1,
          height: 1.6,
          depth: 1,
          centerX: 1,
          centerY: 1,
          centerZ: 1
        }
      });

注意环状饼图是要设置内外半径的

文本标签位置=innerRadius + 0.5 * (outerRadius-innerRadius);

20230619_173737.gif

Github地址

https://github.com/xiaolidan00/my-three

相关文章
|
13天前
|
数据可视化 数据挖掘 Python
Matplotlib图表类型详解:折线图、柱状图与散点图
【4月更文挑战第17天】本文介绍了Python数据可视化库Matplotlib的三种主要图表类型:折线图、柱状图和散点图。折线图用于显示数据随时间或连续变量的变化趋势,适合多条曲线对比;柱状图适用于展示分类数据的数值大小和比较;散点图则用于揭示两个变量之间的关系和模式。通过示例代码展示了如何使用Matplotlib创建这些图表。
|
6月前
uCharts实现一个叠堆柱状图
uCharts实现一个叠堆柱状图
73 1
|
7月前
30Echarts - 柱状图(柱状图框选)
30Echarts - 柱状图(柱状图框选)
22 0
|
10月前
|
Web App开发 XML JSON
echarts的series——折线图,饼图,柱状图,散点图的配置
echarts的series——折线图,饼图,柱状图,散点图的配置
291 0
|
开发者 Python
matplotlib画折线图、直方图、饼图、散点图等常见图形
matplotlib画折线图、直方图、饼图、散点图等常见图形
207 0
matplotlib画折线图、直方图、饼图、散点图等常见图形
|
定位技术
pyecharts绘制条形图、饼图、散点图、词云图、地图等常用图形(二)
pyecharts绘制条形图、饼图、散点图、词云图、地图等常用图形
133 0
pyecharts绘制条形图、饼图、散点图、词云图、地图等常用图形(二)
|
数据可视化 数据挖掘 定位技术
pyecharts绘制条形图、饼图、散点图、词云图、地图等常用图形(一)
pyecharts绘制条形图、饼图、散点图、词云图、地图等常用图形
286 0
pyecharts绘制条形图、饼图、散点图、词云图、地图等常用图形(一)
折线图
折线图
75 0
折线图
饼状图
饼状图
70 0
饼状图