Skip to content

55. 实战:3D 饼图(二)

Published:

上节把饼图的形状画出来了:

image.png

这节给它加上交互。

首先加上点击时的处理:

image.png

renderer.domElement.addEventListener('click', (e) => {
    const y = -((e.offsetY / height) * 2 - 1);
    const x = (e.offsetX / width) * 2 - 1;
  
    const rayCaster = new THREE.Raycaster();
    rayCaster.setFromCamera(new THREE.Vector2(x, y), camera);
  
    const intersections = rayCaster.intersectObjects(mesh.children);
    
    if(intersections.length) {
      const obj = intersections[0].object;
      console.log(obj);
    }
});

看下效果:

2025-04-20 11.21.29.gif

然后在每个 part 上记录下角度:

image.png

mesh.angle = (endAngle + startAngle) / 2;

记录下中间的角度。

然后点击的时候做一下 position.x、position.y 的移动

image.png

mesh.traverse(obj => {
    obj.position.x = 0;
    obj.position.y = 0;
});

obj.position.x = 100 * Math.cos(obj.angle);
obj.position.y = 100 * Math.sin(obj.angle);

把其他 part 位置移动回去,把当前 part 按照角度移动 x、y

2025-04-20 11.35.30.gif

没啥问题。

然后我们加上 tween 动画。

其实 tween.js 包也可以不单独安装,threejs 内置了这个包,直接引入就行:

image.png

你输入 Tween,编辑器会自动从 threejs 下给你引入它。

image.png

const tweenGroup = new Group();

function render() {
    tweenGroup.update();
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

首先创建一个 tweenGroup 来管理多个 tween

然后把直接修改 position 改为 tween 动画:

image.png

const tween = new Tween(obj.position).to({
    x: 100 * Math.cos(obj.angle),
    y: 100 * Math.sin(obj.angle)
}, 500)
.easing(Easing.Quadratic.InOut)
.repeat(0)
.onComplete(() => {
    tweenGroup.remove(tween)
})
.start();
tweenGroup.add(tween);

从当前 position 移动到目标为止,不重复。

把它添加到 tweenGroup,在动画完成的时候从 tweenGroup 中删掉。

看下效果:

2025-04-20 11.42.33.gif

没啥问题。

其他部分的移动同样可以改为 tween 动画:

image.png

const tween = new Tween(obj.position).to({
    x: 0,
    y: 0
}, 500)
.easing(Easing.Quadratic.InOut)
.repeat(0)
.onComplete(() => {
    tweenGroup.remove(tween)
})
.start();
tweenGroup.add(tween);

2025-04-20 11.45.15.gif

最后,我们再给它加上标签,这个用 Sprite 来做:

创建 src/lable.js

import * as THREE from 'three';

function createCanvas(text, img) {
    const canvas = document.createElement("canvas");
    const dpr = window.devicePixelRatio;
    const w = canvas.width = 100 * dpr;
    const h = canvas.height = 50 * dpr;

    const c = canvas.getContext('2d');
    c.translate(w / 2, h / 2);
    c.fillStyle = "#ffffff";
    c.font = "normal 32px 微软雅黑";
    c.textBaseline = "middle";
    c.textAlign = "center";
    c.fillText(text, 0, 0);
    return canvas;
}

export default function createLabel(text) {
    const texture = new THREE.CanvasTexture(createCanvas(text));

    const spriteMaterial = new THREE.SpriteMaterial({
        map: texture
    });

    const label = new THREE.Sprite(spriteMaterial);
    label.scale.set(200, 100);
    return label;
}

用 canvas 把传入的文字画出来,作为 Sprite 的颜色贴图。

然后加到 pie 的每个 part 上:

image.png

const label = createLabel(data[i].value);
label.position.x = 200 * Math.cos(mesh.angle);
label.position.y = 200 * Math.sin(mesh.angle);
label.position.z = 150;
mesh.add(label);

z 是固定的,x、y 根据 angle 来算。

看下效果:

image.png

但是现在一点,标签就到中间去了:

2025-04-20 12.07.50.gif

因为之前遍历的时候没有区分:

image.png

过滤下 Sprite,不做位移:

image.png

if(obj.isSprite) {
    return;
}

2025-04-20 12.10.03.gif

这样就好了。

此外,要处理下点击到 Sprite 的情况:

2025-04-20 12.12.36.gif

现在点击到 part 是正常的,但点到 Sprite 就有问题了。

处理方法和之前一样,加个 target 属性:

image.png

label.target = mesh;
mesh.target = mesh;

image.png

2025-04-20 12.12.11.gif

现在 Sprite 标签显示的内容不全,我们再加上 name 部分:

image.png

image.png

const label = createLabel(data[i].name + ' ' + data[i].value);

但这样就显示不全了:

image.png

这样就不能写死 Sprite 的宽度了,要根据字符串长度动态计算:

image.png

宽度根据 text.length * 30 来计算。

同时 canvas 的高度为 50 * dpr,那 label 的高度也要设置 50

image.png

width 用传入的来设置

文字大小也要乘以 dpr,就是一个文字的宽度 30 * 2

import * as THREE from 'three';

function createCanvas(text, width) {
    const canvas = document.createElement("canvas");
    const dpr = window.devicePixelRatio;
    const w = canvas.width = width * dpr;
    const h = canvas.height = 50 * dpr;

    const c = canvas.getContext('2d');
    c.translate(w / 2, h / 2);
    c.fillStyle = "#ffffff";
    c.font = "normal 60px 微软雅黑";
    c.textBaseline = "middle";
    c.textAlign = "center";
    c.fillText(text, 0, 0);
    return canvas;
}

export default function createLabel(text) {
    const texture = new THREE.CanvasTexture(createCanvas(text, text.length * 30));

    const spriteMaterial = new THREE.SpriteMaterial({
        map: texture
    });

    const label = new THREE.Sprite(spriteMaterial);
    label.scale.set(text.length * 30, 50);
    return label;
}

看下效果:

2025-04-20 12.30.24.gif

当然,这里用上节学的 SpriteText 会更简单,大家可以自己换一下。

这样就好了,然后我们稍微调一下标签位置,往外一点:

image.png

2025-04-20 12.32.15.gif

这样,3D 饼图就完成了。

案例代码上传了小册仓库

总结

这节我们给饼图加上了点击的交互,Sprite 标签,以及 tween.js 缓动动画。

首先用 RayCaster 加上点击的处理,给点击的 part 修改 position,具体的 position 要根据角度的 cos、sin 来计算出来。

之后加上了 canvas + Sprite 画的标签,这里要注意 dpr 的问题,Sprite 的高度为 50,那 canvas 的高度就是 50 * dpr,这样正好不模糊,字体大小也要乘以 2,比如 30px * 2 = 60px。

这样,3D 饼图的实战就完成了,难点在于角度、cos、sin 的计算,以及 canvas 的尺寸设置。

评论