<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cake</title>
<style>
#cake {
height: 500px;
width: 500px;
background-color: black;
}
</style>
</head>
<body>
<div id="cake"></div>
<script type="module">
import * as THREE from './node_modules/three/build/three.module.js';
import ThreeBase from './ThreeBase.js';
import { getShadowColor, getBasicMaterial, getTextArraySprite, getDrawColors } from './util.js';
class Cake extends ThreeBase {
constructor() {
super();
this.isStats = false;
this.isAxis = false;
this.rotateAngle = 0;
this.count = 0;
this.time = 0;
this.currentTextMesh = null;
}
createChart(that) {
this.that = that;
if (this.group) {
this.cleanObj(this.group);
this.group = null;
}
if (that.data.length == 0) {
return;
}
this.cLen = 4;
//获取渐变色
this.colors = getDrawColors(that.colors, this.cLen);
// this.colors = that.colors;
console.log('[ colors ] >', this.colors, this.cLen);
//从小到大排序
that.data = that.data.sort((a, b) => a.value - b.value);
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;
}
}
}
}
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 },
// { name: '大学', value: 300 },
],
//是否显示标签
isLabel: false,
//最大高度
maxHeight: 20,
//基础高度
baseHeight: 10,
//半径
radius: 30,
//单位后缀
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,
},
});
</script>
</body>
</html>
import * as THREE from './node_modules/three/build/three.module.js';
import { OrbitControls } from './node_modules/three/examples/jsm/controls/OrbitControls.js';
import Stats from './node_modules/three/examples/jsm/libs/stats.module.js';
export default class ThreeBase {
constructor() {
this.isModelRGB = false;
this.isStats = false;
this.isAxis = false;
this.isRaycaster = false;
this.initCameraPos = [0, 100, 0];
}
initRaycaster() {
this.raycaster = new THREE.Raycaster();
this.mouseHover();
this.mouseClick();
}
mouseClick() {
this.mouse = new THREE.Vector2();
this.container.style.cursor = 'pointer';
this.container.addEventListener('pointerdown', (event) => {
console.log('click');
event.preventDefault();
this.mouse.x =
((event.offsetX - this.container.offsetLeft) / this.container.offsetWidth) * 2 - 1;
this.mouse.y =
-((event.offsetY - this.container.offsetTop) / this.container.offsetHeight) * 2 + 1;
let vector = new THREE.Vector3(this.mouse.x, this.mouse.y, 1).unproject(this.camera);
this.raycaster.set(this.camera.position, vector.sub(this.camera.position).normalize());
this.raycaster.setFromCamera(this.mouse, this.camera);
this.raycasterAction();
});
}
mouseHover() {
this.mouse1 = new THREE.Vector2();
this.container.addEventListener('pointermove', (event) => {
event.preventDefault();
this.mouse1.x =
((event.offsetX - this.container.offsetLeft) / this.container.offsetWidth) * 2 - 1;
this.mouse1.y =
-((event.offsetY - this.container.offsetTop) / this.container.offsetHeight) * 2 + 1;
let vector = new THREE.Vector3(this.mouse1.x, this.mouse1.y, 1).unproject(this.camera);
this.raycaster.set(this.camera.position, vector.sub(this.camera.position).normalize());
this.raycaster.setFromCamera(this.mouse1, this.camera);
this.mouseHoverAction();
});
}
mouseHoverAction() {}
raycasterAction() {}
createChart(that) {}
getCameraControl() {
console.log(this.camera.position);
console.log(this.controls.target);
}
initThree(el) {
window.ThreeBase = this;
this.container = el;
THREE.Cache.enabled = true;
this.renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
logarithmicDepthBuffer: this.isDepthBuffer || false
});
this.renderer.setClearColor(0x000000, 0);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(this.container.offsetWidth, this.container.offsetHeight);
if (this.isModelRGB) {
this.renderer.outputEncoding = THREE.sRGBEncoding;
}
this.renderer.shadowMap.enable = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.container.appendChild(this.renderer.domElement);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(
40,
this.container.offsetWidth / this.container.offsetHeight,
1,
100000
);
this.camera.position.set(...this.initCameraPos);
if (this.isAxis) {
const axesHelper = new THREE.AxesHelper(500);
this.scene.add(axesHelper);
}
if (this.isStats) {
this.stats = new Stats();
this.stats.domElement.style.position = 'absolute';
this.stats.domElement.style.top = '0px';
this.container.appendChild(this.stats.domElement);
}
if (this.isRaycaster) {
this.initRaycaster();
}
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
window.addEventListener('resize', this.onResize.bind(this));
window.addEventListener('unload', this.cleanAll.bind(this));
this.animateRender();
}
saveImage() {
let image = this.renderer.domElement.toDataURL('image/png');
let parts = image.split(';base64,');
let contentType = parts[0].split(':')[1];
let raw = window.atob(parts[1]);
let rawLength = raw.length;
let uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; i++) {
uInt8Array[i] = raw.charCodeAt(i);
}
const fileName = new Date().getTime() + '.png';
const file = new File([uInt8Array], fileName, { type: contentType });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(file);
link.download = fileName;
link.target = '_blank';
link.style.display = 'none';
document.body.appendChild(link);
link.click();
window.URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}
animateRender() {
if (this.isStats && this.stats) {
this.stats.update();
}
if (this.controls) {
this.controls.update();
}
this.animateAction();
this.renderer.render(this.scene, this.camera);
this.threeAnim = requestAnimationFrame(this.animateRender.bind(this));
}
//执行动画动作
animateAction() {}
cleanNext(obj, idx) {
if (idx < obj.children.length) {
this.cleanElmt(obj.children[idx]);
}
if (idx + 1 < obj.children.length) {
this.cleanNext(obj, idx + 1);
}
}
setView(cameraPos, controlPos) {
this.camera.position.set(cameraPos.x, cameraPos.y, cameraPos.z);
this.controls.target.set(controlPos.x, controlPos.y, controlPos.z);
}
getView() {
console.log('camera', this.camera.position);
console.log('controls', this.controls.target);
}
cleanElmt(obj) {
if (obj) {
if (obj.children && obj.children.length > 0) {
this.cleanNext(obj, 0);
obj.remove(...obj.children);
}
if (obj.geometry) {
obj.geometry.dispose && obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach((m) => {
this.cleanElmt(m);
});
} else {
for (const v of Object.values(obj.material)) {
if (v instanceof THREE.Texture) {
v.dispose && v.dispose();
}
}
obj.material.dispose && obj.material.dispose();
}
}
obj.dispose && obj.dispose();
obj.clear && obj.clear();
}
}
setModelCenter(object, viewControl) {
if (!object) {
return;
}
if (object.updateMatrixWorld) {
object.updateMatrixWorld();
}
// 获得包围盒得min和max
const box = new THREE.Box3().setFromObject(object);
let objSize = box.getSize();
// 返回包围盒的中心点
const center = box.getCenter(new THREE.Vector3());
object.position.x += object.position.x - center.x;
object.position.y += object.position.y - center.y;
object.position.z += object.position.z - center.z;
let width = objSize.x;
let height = objSize.y;
let depth = objSize.z;
let centroid = new THREE.Vector3().copy(objSize);
centroid.multiplyScalar(0.5);
if (viewControl.autoCamera) {
this.camera.position.x =
centroid.x * (viewControl.centerX || 0) + width * (viewControl.width || 0);
this.camera.position.y =
centroid.y * (viewControl.centerY || 0) + height * (viewControl.height || 0);
this.camera.position.z =
centroid.z * (viewControl.centerZ || 0) + depth * (viewControl.depth || 0);
} else {
this.camera.position.set(
viewControl.cameraPosX || 0,
viewControl.cameraPosY || 0,
viewControl.cameraPosZ || 0
);
}
this.camera.lookAt(0, 0, 0);
}
cleanObj(obj) {
this.cleanElmt(obj);
obj?.parent?.remove && obj.parent.remove(obj);
}
cleanAll() {
cancelAnimationFrame(this.threeAnim);
window.removeEventListener('resize', this.onResize.bind(this));
if (this.stats) {
this.container.removeChild(this.stats.domElement);
this.stats = null;
}
this.cleanObj(this.scene);
this.controls && this.controls.dispose();
this.renderer.renderLists && this.renderer.renderLists.dispose();
this.renderer.dispose && this.renderer.dispose();
this.renderer.forceContextLoss();
let gl = this.renderer.domElement.getContext('webgl');
gl && gl.getExtension('WEBGL_lose_context').loseContext();
this.renderer.setAnimationLoop(null);
this.renderer.domElement = null;
this.renderer.content = null;
console.log('清空资源', this.renderer.info);
this.renderer = null;
THREE.Cache.clear();
}
setModelCenter(object, viewControl) {
if (!object) {
return;
}
if (object.updateMatrixWorld) {
object.updateMatrixWorld();
}
// 获得包围盒得min和max
const box = new THREE.Box3().setFromObject(object);
let objSize = box.getSize();
// 返回包围盒的中心点
const center = box.getCenter(new THREE.Vector3());
object.position.x += object.position.x - center.x;
object.position.y += object.position.y - center.y;
object.position.z += object.position.z - center.z;
let width = objSize.x;
let height = objSize.y;
let depth = objSize.z;
let centroid = new THREE.Vector3().copy(objSize);
centroid.multiplyScalar(0.5);
if (viewControl.autoCamera) {
this.camera.position.x =
centroid.x * (viewControl.centerX || 0) + width * (viewControl.width || 0);
this.camera.position.y =
centroid.y * (viewControl.centerY || 0) + height * (viewControl.height || 0);
this.camera.position.z =
centroid.z * (viewControl.centerZ || 0) + depth * (viewControl.depth || 0);
} else {
this.camera.position.set(
viewControl.cameraPosX || 0,
viewControl.cameraPosY || 0,
viewControl.cameraPosZ || 0
);
}
this.camera.lookAt(0, 0, 0);
}
onResize() {
if (this.container) {
this.camera.aspect = this.container.offsetWidth / this.container.offsetHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(this.container.offsetWidth, this.container.offsetHeight);
}
}
}