450 lines
14 KiB
TypeScript
450 lines
14 KiB
TypeScript
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(20, width / height, 0.1, 1000);
|
||
camera.position.set(1.5, 0.2, 0.5); // 从左下后方观察,使Z轴指向右上方
|
||
camera.lookAt(0, 0, 0); // 确保相机朝向原点
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setSize(width, height);
|
||
renderer.setClearColor(0xFFFFFF, 1); // 设置背景为透明
|
||
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.01,
|
||
sizeAttenuation: true,
|
||
transparent: true,
|
||
alphaTest: 0.5,
|
||
depthWrite: false,
|
||
blending: THREE.NormalBlending,
|
||
map: (() => {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 64;
|
||
canvas.height = 64;
|
||
const ctx = canvas.getContext('2d')!;
|
||
ctx.clearRect(0, 0, 64, 64);
|
||
// 绘制带有alpha渐变的圆形
|
||
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 30);
|
||
gradient.addColorStop(0, 'rgba(255,255,255,1)');
|
||
gradient.addColorStop(0.98, 'rgba(255,255,255,1)');
|
||
gradient.addColorStop(1, 'rgba(255,255,255,0)');
|
||
ctx.beginPath();
|
||
ctx.arc(32, 32, 30, 0, 2 * Math.PI);
|
||
ctx.closePath();
|
||
ctx.fillStyle = gradient;
|
||
ctx.fill();
|
||
return new THREE.CanvasTexture(canvas);
|
||
})()
|
||
});
|
||
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.y += 0.002; // 围绕Z轴更慢速旋转
|
||
}
|
||
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
};
|
||
animate();
|
||
|
||
// 只在非分类数据时在3D图形内显示图例
|
||
if (!isCategorical) {
|
||
const legend = document.createElement("div");
|
||
legend.className = "point-cloud-legend";
|
||
|
||
// 数值数据图例
|
||
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();
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
// 生成图例内容
|
||
const renderLegend = () => {
|
||
if (isCategorical) {
|
||
// 分类数据图例
|
||
const cellTypes = Array.from(new Set(data.map(p => p.value as string))).sort();
|
||
const colorMap = generateCellTypeColors(cellTypes);
|
||
|
||
return (
|
||
<div>
|
||
<div style={{ fontWeight: '600', marginBottom: '10px', fontSize: '14px' }}>细胞类型</div>
|
||
<div>
|
||
{cellTypes.map(type => {
|
||
const cellColor = colorMap.get(type);
|
||
if (cellColor) {
|
||
const hexColor = `#${cellColor.getHexString()}`;
|
||
return (
|
||
<div key={type} style={{ display: 'flex', alignItems: 'center', marginBottom: '5px' }}>
|
||
<div
|
||
style={{
|
||
width: '12px',
|
||
height: '12px',
|
||
borderRadius: '50%',
|
||
backgroundColor: hexColor,
|
||
marginRight: '8px'
|
||
}}
|
||
/>
|
||
<span style={{ fontSize: '12px' }}>{type}</span>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
} else {
|
||
// 数值数据图例
|
||
return (
|
||
<div>
|
||
<div style={{ fontWeight: '600', marginBottom: '10px', fontSize: '14px' }}>表达强度</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||
<div style={{
|
||
background: 'linear-gradient(to top, hsla(180, 100%, 50%, 1.00), hsla(72, 100%, 50%, 1.00), hsla(0, 100%, 50%, 0.99))',
|
||
width: '20px',
|
||
height: '80px',
|
||
marginBottom: '5px'
|
||
}} />
|
||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||
<div>高</div>
|
||
<div>低</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
};
|
||
|
||
// 根据isCategorical返回不同的布局
|
||
if (isCategorical) {
|
||
return (
|
||
<div className="point-cloud-visualization-container" style={{
|
||
display: 'flex',
|
||
width: '100%',
|
||
gap: '0',
|
||
height: '100%'
|
||
}}>
|
||
{/* 左侧:3D图形 */}
|
||
<div style={{
|
||
flex: '1',
|
||
position: 'relative',
|
||
height: '100%',
|
||
minHeight: '400px'
|
||
}}>
|
||
<div ref={containerRef} className="point-cloud-container" style={{ width: '100%', height: '100%' }} />
|
||
</div>
|
||
|
||
{/* 右侧:图例 */}
|
||
<div style={{
|
||
width: '200px',
|
||
padding: '15px',
|
||
backgroundColor: 'white',
|
||
border: '1px solid #e2e8f0',
|
||
height: 'fit-content',
|
||
maxHeight: '100%',
|
||
overflowY: 'auto',
|
||
flexShrink: 0
|
||
}}>
|
||
{renderLegend()}
|
||
</div>
|
||
</div>
|
||
);
|
||
} else {
|
||
// 原始单列布局
|
||
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;
|