用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();
};
- 开启动画
- 不开启动画时
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);
Github地址
https://github.com/xiaolidan00/my-three