digital-embryo/embryo-frontend/src/components/PointCloud.tsx

350 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
interface Point {
x: number;
y: number;
z: number;
value: number | string; // 表达强度或细胞类型
}
interface PointCloudProps {
data: Point[];
isCategorical?: boolean; // 是否为分类数据(细胞类型)
autoRotate?: boolean; // 是否自动旋转
}
const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, autoRotate = false }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
const materialRef = useRef<THREE.PointsMaterial | null>(null);
const animationIdRef = useRef<number | null>(null);
const controlsRef = useRef<any>(null);
const [isSpinning, setIsSpinning] = useState(autoRotate);
const isSpinningRef = useRef(autoRotate);
const checkboxId = useRef(`spin-checkbox-${Math.random().toString(36).substring(2, 11)}`).current;
// 为细胞类型生成颜色映射
const generateCellTypeColors = (cellTypes: string[]) => {
const colorMap = new Map<string, THREE.Color>();
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F8C471', '#82E0AA', '#F1948A', '#85CDCE', '#D7BDE2',
'#A9DFBF', '#F9E79F', '#D5A6BD', '#AED6F1', '#E8DAEF'
];
cellTypes.forEach((type, index) => {
const colorHex = colors[index % colors.length];
colorMap.set(type, new THREE.Color(colorHex));
});
return colorMap;
};
useEffect(() => {
if (!containerRef.current || data.length === 0) return;
// 确保ref与当前状态同步
isSpinningRef.current = isSpinning;
// 清理之前的资源
if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
if (rendererRef.current) {
rendererRef.current.dispose();
rendererRef.current = null;
}
if (geometryRef.current) {
geometryRef.current.dispose();
geometryRef.current = null;
}
if (materialRef.current) {
materialRef.current.dispose();
materialRef.current = null;
}
// 清除旧渲染内容
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
}
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
// 初始化场景、相机、渲染器
const scene = new THREE.Scene();
sceneRef.current = scene; // 存储scene引用
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
camera.position.set(-15, 15, -12); // 从左下后方观察使Z轴指向右上方
camera.lookAt(0, 0, 0); // 确保相机朝向原点
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
rendererRef.current = renderer;
containerRef.current.appendChild(renderer.domElement);
// 控件
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controlsRef.current = controls;
// 坐标轴辅助
const axesHelper = new THREE.AxesHelper(10);
scene.add(axesHelper);
// 点云数据
const geometry = new THREE.BufferGeometry();
geometryRef.current = geometry;
const positions: number[] = [];
const colors: number[] = [];
const sizes: number[] = [];
let colorMap: Map<string, THREE.Color> | null = null;
let cellTypes: string[] = [];
let maxValue = 0;
if (isCategorical) {
// 分类数据处理
cellTypes = Array.from(new Set(data.map(p => p.value as string))).sort();
colorMap = generateCellTypeColors(cellTypes);
} else {
// 数值数据处理
maxValue = Math.max(...data.map((p) => p.value as number));
}
const color = new THREE.Color();
data.forEach((point) => {
positions.push(point.x, point.y, point.z);
if (isCategorical && colorMap) {
// 分类数据:按细胞类型着色
const cellColor = colorMap.get(point.value as string) || new THREE.Color('#CCCCCC');
colors.push(cellColor.r, cellColor.g, cellColor.b);
sizes.push(0.8); // 统一大小
} else {
// 数值数据:按表达强度着色
const value = point.value as number;
color.setHSL(0.6 * (1 - value / maxValue), 1.0, 0.5);
colors.push(color.r, color.g, color.b);
sizes.push(0.3 + 1.5 * value / maxValue);
}
});
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
geometry.setAttribute("size", new THREE.Float32BufferAttribute(sizes, 1));
const material = new THREE.PointsMaterial({
vertexColors: true,
size: 0.5,
sizeAttenuation: true,
});
materialRef.current = material;
const points = new THREE.Points(geometry, material);
scene.add(points);
// 点击点检测raycaster
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
const onClick = (event: MouseEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(points);
if (intersects.length > 0) {
const i = intersects[0].index ?? 0;
const p = data[i];
console.log(`Clicked: x=${p.x}, y=${p.y}, z=${p.z}, value=${p.value}`);
if (isCategorical) {
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nCell Type: ${p.value}`);
} else {
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nExpression: ${(p.value as number).toFixed(3)}`);
}
}
};
renderer.domElement.addEventListener("click", onClick);
// 渲染循环
const animate = () => {
animationIdRef.current = requestAnimationFrame(animate);
// 自动旋转逻辑 - 使用 ref 来避免重新创建场景
if (isSpinningRef.current && autoRotate) {
scene.rotation.z += 0.002; // 围绕Z轴更慢速旋转
}
controls.update();
renderer.render(scene, camera);
};
animate();
// 图例
const legend = document.createElement("div");
legend.className = "point-cloud-legend";
if (isCategorical && colorMap) {
// 分类数据图例
let legendHTML = '<div class="legend-title">细胞类型</div>';
cellTypes.forEach(type => {
const cellColor = colorMap!.get(type);
if (cellColor) {
const hexColor = `#${cellColor.getHexString()}`;
legendHTML += `
<div class="legend-item">
<div class="legend-color" style="background-color: ${hexColor}"></div>
<span class="legend-label">${type}</span>
</div>
`;
}
});
legend.innerHTML = legendHTML;
} else {
// 数值数据图例
legend.innerHTML = `
<div class="legend-gradient"></div>
<div>↑ High</div>
<div>↓ Low</div>`;
}
containerRef.current.appendChild(legend);
// 清理
return () => {
// 停止动画循环
if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
// 移除事件监听器
if (rendererRef.current && rendererRef.current.domElement) {
rendererRef.current.domElement.removeEventListener("click", onClick);
}
// 清理控制器
if (controlsRef.current) {
controlsRef.current.dispose();
controlsRef.current = null;
}
// 释放Three.js资源
if (rendererRef.current) {
rendererRef.current.dispose();
rendererRef.current = null;
}
if (geometryRef.current) {
geometryRef.current.dispose();
geometryRef.current = null;
}
if (materialRef.current) {
materialRef.current.dispose();
materialRef.current = null;
}
};
}, [data, isCategorical, autoRotate]);
// 监听autoRotate prop变化重置旋转状态
useEffect(() => {
setIsSpinning(autoRotate);
isSpinningRef.current = autoRotate;
}, [autoRotate]);
// 监听isSpinning状态变化只更新ref不重新创建场景
useEffect(() => {
isSpinningRef.current = isSpinning;
}, [isSpinning]);
// 组件卸载时的最终清理
useEffect(() => {
return () => {
if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current);
}
if (controlsRef.current) {
controlsRef.current.dispose();
}
if (rendererRef.current) {
rendererRef.current.dispose();
}
if (geometryRef.current) {
geometryRef.current.dispose();
}
if (materialRef.current) {
materialRef.current.dispose();
}
};
}, []);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div ref={containerRef} className="point-cloud-container" />
{autoRotate === true && (
<div className="spin-control" style={{
position: 'absolute',
top: '15px',
left: '15px',
background: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '10px 15px',
borderRadius: '8px',
border: '2px solid #fff',
fontSize: '14px',
fontWeight: '500',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
pointerEvents: 'auto'
}}>
<input
type="checkbox"
id={checkboxId}
checked={isSpinning}
onChange={(e) => setIsSpinning(e.target.checked)}
style={{
cursor: 'pointer',
width: '16px',
height: '16px',
accentColor: '#4ECDC4'
}}
/>
<label
htmlFor={checkboxId}
style={{
cursor: 'pointer',
userSelect: 'none',
color: 'white',
fontSize: '14px',
fontWeight: '500'
}}
>
Auto Spin
</label>
</div>
)}
</div>
);
};
export default PointCloud;