445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
||
import axios from "axios";
|
||
import * as THREE from "three";
|
||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||
import "../style.css";
|
||
|
||
interface Point {
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
value: number | string;
|
||
}
|
||
|
||
const EmbryoGeneration: React.FC = () => {
|
||
const [cs7Data, setCs7Data] = useState<Point[]>([]);
|
||
const [cs8Data, setCs8Data] = useState<Point[]>([]);
|
||
const [cs75Data, setCs75Data] = useState<Point[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [animationProgress, setAnimationProgress] = useState(0);
|
||
const [isAnimating, setIsAnimating] = useState(false);
|
||
const [animationSpeed, setAnimationSpeed] = useState(2);
|
||
|
||
// 加载数据
|
||
useEffect(() => {
|
||
const loadData = async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const [cs7Res, cs8Res, cs75Res] = await Promise.all([
|
||
axios.get("http://localhost:5000/api/cell", { params: { stage: "CS7" } }),
|
||
axios.get("http://localhost:5000/api/cell", { params: { stage: "CS8" } }),
|
||
axios.get("http://localhost:5000/api/cell", { params: { stage: "CS7.5" } })
|
||
]);
|
||
setCs7Data(cs7Res.data.cells);
|
||
setCs8Data(cs8Res.data.cells);
|
||
setCs75Data(cs75Res.data.cells);
|
||
} catch (err: any) {
|
||
if (err.response && err.response.data?.error) {
|
||
setError(err.response.data.error);
|
||
} else {
|
||
setError("Failed to fetch embryo data.");
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
loadData();
|
||
}, []);
|
||
|
||
// 动画逻辑
|
||
useEffect(() => {
|
||
if (!isAnimating || cs7Data.length === 0 || cs8Data.length === 0 || cs75Data.length === 0) {
|
||
return;
|
||
}
|
||
const duration = 5000 / animationSpeed;
|
||
const startTime = Date.now();
|
||
const animate = () => {
|
||
const elapsed = Date.now() - startTime;
|
||
const progress = Math.min(elapsed / duration, 1);
|
||
setAnimationProgress(progress);
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate);
|
||
} else {
|
||
setIsAnimating(false);
|
||
setAnimationProgress(1);
|
||
}
|
||
};
|
||
animate();
|
||
}, [isAnimating, cs7Data.length, cs8Data.length, cs75Data.length, animationSpeed]);
|
||
|
||
const startAnimation = () => {
|
||
setIsAnimating(true);
|
||
setAnimationProgress(0);
|
||
};
|
||
const resetAnimation = () => {
|
||
setIsAnimating(false);
|
||
setAnimationProgress(0);
|
||
};
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div className="page-header">
|
||
<h1>Embryo Generation</h1>
|
||
<p className="page-description">
|
||
观察胚胎发育过程中的细胞迁移和融合。CS7、CS8、CS7.5点云在同一3D视图中展示,动画模拟细胞从两侧汇聚到中间。
|
||
</p>
|
||
</div>
|
||
<div className="page-content">
|
||
<div className="control-panel" style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: "2rem",
|
||
marginBottom: "2rem",
|
||
padding: "1rem",
|
||
backgroundColor: "var(--bg-secondary)",
|
||
borderRadius: "8px",
|
||
flexWrap: "wrap"
|
||
}}>
|
||
<button
|
||
onClick={startAnimation}
|
||
disabled={isAnimating || loading}
|
||
style={{
|
||
padding: "0.75rem 1.5rem",
|
||
backgroundColor: "var(--primary-color)",
|
||
color: "white",
|
||
border: "none",
|
||
borderRadius: "6px",
|
||
cursor: isAnimating || loading ? "not-allowed" : "pointer",
|
||
opacity: isAnimating || loading ? 0.6 : 1
|
||
}}
|
||
>
|
||
{isAnimating ? "动画中..." : "开始动画"}
|
||
</button>
|
||
<button
|
||
onClick={resetAnimation}
|
||
disabled={loading}
|
||
style={{
|
||
padding: "0.75rem 1.5rem",
|
||
backgroundColor: "var(--secondary-color)",
|
||
color: "white",
|
||
border: "none",
|
||
borderRadius: "6px",
|
||
cursor: loading ? "not-allowed" : "pointer",
|
||
opacity: loading ? 0.6 : 1
|
||
}}
|
||
>
|
||
重置
|
||
</button>
|
||
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||
动画速度:
|
||
<input
|
||
type="range"
|
||
min="0.5"
|
||
max="3"
|
||
step="0.1"
|
||
value={animationSpeed}
|
||
onChange={(e) => setAnimationSpeed(parseFloat(e.target.value))}
|
||
style={{ width: "100px" }}
|
||
/>
|
||
<span>{animationSpeed}x</span>
|
||
</label>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||
进度: {Math.round(animationProgress * 100)}%
|
||
<div style={{
|
||
width: "100px",
|
||
height: "8px",
|
||
backgroundColor: "var(--bg-tertiary)",
|
||
borderRadius: "4px",
|
||
overflow: "hidden"
|
||
}}>
|
||
<div style={{
|
||
width: `${animationProgress * 100}%`,
|
||
height: "100%",
|
||
backgroundColor: "var(--primary-color)",
|
||
transition: "width 0.1s ease"
|
||
}} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{error && <div className="error-message" style={{ marginBottom: "2rem" }}>{error}</div>}
|
||
<UnifiedEmbryoAnimation
|
||
cs7Data={cs7Data}
|
||
cs8Data={cs8Data}
|
||
cs75Data={cs75Data}
|
||
animationProgress={animationProgress}
|
||
/>
|
||
<div className="section">
|
||
<h2>使用说明</h2>
|
||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "2rem", marginTop: "1.5rem" }}>
|
||
<div>
|
||
<h3>🎬 动画控制</h3>
|
||
<p>点击"开始动画"按钮观看CS7.5阶段的细胞生成过程。可以调节动画速度,使用重置按钮重新开始。</p>
|
||
</div>
|
||
<div>
|
||
<h3>🖱️ 交互操作</h3>
|
||
<p>在3D视图中使用鼠标拖拽旋转视角,滚轮缩放,点击数据点查看详细信息。</p>
|
||
</div>
|
||
<div>
|
||
<h3>🎨 颜色编码</h3>
|
||
<p>相同颜色代表相同细胞类型,便于观察细胞在发育过程中的变化。</p>
|
||
</div>
|
||
<div>
|
||
<h3>📊 发育过程</h3>
|
||
<p>观察从CS7和CS8到CS7.5的胚胎发育过程,了解细胞的空间分布变化和类型分化。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface UnifiedEmbryoAnimationProps {
|
||
cs7Data: Point[];
|
||
cs8Data: Point[];
|
||
cs75Data: Point[];
|
||
animationProgress: number;
|
||
}
|
||
|
||
const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data, cs8Data, cs75Data, animationProgress }) => {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||
const controlsRef = useRef<any>(null);
|
||
const animationIdRef = useRef<number | null>(null);
|
||
|
||
// 颜色映射
|
||
const getColorMap = (allData: Point[]) => {
|
||
const cellTypes = Array.from(new Set(allData.map(p => p.value as string))).sort();
|
||
const colors = [
|
||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
|
||
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
|
||
'#F8C471', '#82E0AA', '#F1948A', '#85CDCE', '#D7BDE2',
|
||
'#A9DFBF', '#F9E79F', '#D5A6BD', '#AED6F1', '#E8DAEF'
|
||
];
|
||
const colorMap = new Map<string, THREE.Color>();
|
||
cellTypes.forEach((type, idx) => {
|
||
colorMap.set(type, new THREE.Color(colors[idx % colors.length]));
|
||
});
|
||
return colorMap;
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!containerRef.current) return;
|
||
// 清理
|
||
if (animationIdRef.current !== null) {
|
||
cancelAnimationFrame(animationIdRef.current);
|
||
animationIdRef.current = null;
|
||
}
|
||
if (rendererRef.current) {
|
||
rendererRef.current.dispose();
|
||
rendererRef.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;
|
||
const camera = new THREE.PerspectiveCamera(30, width / height, 0.1, 1000);
|
||
camera.position.set(0, 0, 30);
|
||
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 allData = [...cs7Data, ...cs8Data, ...cs75Data];
|
||
const colorMap = getColorMap(allData);
|
||
|
||
// 计算所有点的包围盒,自动居中和缩放
|
||
let allPositions: number[] = [];
|
||
cs7Data.forEach(p => { allPositions.push(p.x - 40, p.y, p.z); });
|
||
cs8Data.forEach(p => { allPositions.push(p.x + 40, p.y, p.z); });
|
||
cs75Data.forEach((p, i) => {
|
||
const fromX = i % 2 === 0 ? p.x - 40 : p.x + 40;
|
||
allPositions.push(fromX, p.y, p.z);
|
||
allPositions.push(p.x, p.y, p.z);
|
||
});
|
||
if (allPositions.length > 0) {
|
||
const posArr = new Float32Array(allPositions);
|
||
const box = new THREE.Box3().setFromArray(posArr);
|
||
const center = new THREE.Vector3();
|
||
box.getCenter(center);
|
||
const size = new THREE.Vector3();
|
||
box.getSize(size);
|
||
// 让最大边长适配canvas
|
||
const maxDim = Math.max(size.x, size.y, size.z);
|
||
const fitScale = 32 / maxDim; // 32为经验缩放系数
|
||
scene.position.set(-center.x, -center.y, -center.z);
|
||
scene.scale.set(fitScale, fitScale, fitScale);
|
||
}
|
||
|
||
// CS7点云(左,静态)
|
||
if (cs7Data.length > 0) {
|
||
const geometry = new THREE.BufferGeometry();
|
||
const positions: number[] = [];
|
||
const colors: number[] = [];
|
||
cs7Data.forEach((p) => {
|
||
positions.push(p.x - 40, p.y, p.z);
|
||
const c = colorMap.get(p.value as string) || new THREE.Color('#CCCCCC');
|
||
colors.push(c.r, c.g, c.b);
|
||
});
|
||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
vertexColors: true,
|
||
size: 0.7,
|
||
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);
|
||
})()
|
||
});
|
||
const points = new THREE.Points(geometry, material);
|
||
scene.add(points);
|
||
}
|
||
// CS8点云(右,静态)
|
||
if (cs8Data.length > 0) {
|
||
const geometry = new THREE.BufferGeometry();
|
||
const positions: number[] = [];
|
||
const colors: number[] = [];
|
||
cs8Data.forEach((p) => {
|
||
positions.push(p.x + 40, p.y, p.z);
|
||
const c = colorMap.get(p.value as string) || new THREE.Color('#CCCCCC');
|
||
colors.push(c.r, c.g, c.b);
|
||
});
|
||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
vertexColors: true,
|
||
size: 0.7,
|
||
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);
|
||
})()
|
||
});
|
||
const points = new THREE.Points(geometry, material);
|
||
scene.add(points);
|
||
}
|
||
// CS7.5点云(动画)
|
||
if (cs75Data.length > 0) {
|
||
const geometry = new THREE.BufferGeometry();
|
||
const positions: number[] = [];
|
||
const colors: number[] = [];
|
||
const n = cs75Data.length;
|
||
const moveDuration = 0.4; // 每个点的动画持续时间(占总进度的比例)
|
||
const delayPerPoint = 0.6 / n; // 剩余0.6进度用于错峰
|
||
for (let i = 0; i < n; i++) {
|
||
const to = cs75Data[i];
|
||
const fromX = i % 2 === 0 ? to.x - 40 : to.x + 40;
|
||
const fromY = to.y;
|
||
const fromZ = to.z;
|
||
const delay = i * delayPerPoint;
|
||
let pointProgress = (animationProgress - delay) / moveDuration;
|
||
pointProgress = Math.max(0, Math.min(1, pointProgress));
|
||
const x = fromX + (to.x - fromX) * pointProgress;
|
||
const y = fromY + (to.y - fromY) * pointProgress;
|
||
const z = fromZ + (to.z - fromZ) * pointProgress;
|
||
positions.push(x, y, z);
|
||
const c = colorMap.get(to.value as string) || new THREE.Color('#CCCCCC');
|
||
colors.push(c.r, c.g, c.b);
|
||
}
|
||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
vertexColors: true,
|
||
size: 0.7,
|
||
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);
|
||
})()
|
||
});
|
||
const points = new THREE.Points(geometry, material);
|
||
scene.add(points);
|
||
}
|
||
// 渲染循环
|
||
const animate = () => {
|
||
animationIdRef.current = requestAnimationFrame(animate);
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
};
|
||
animate();
|
||
// 清理
|
||
return () => {
|
||
if (animationIdRef.current !== null) {
|
||
cancelAnimationFrame(animationIdRef.current);
|
||
}
|
||
if (controlsRef.current) {
|
||
controlsRef.current.dispose();
|
||
}
|
||
if (rendererRef.current) {
|
||
rendererRef.current.dispose();
|
||
}
|
||
};
|
||
}, [cs7Data, cs8Data, cs75Data, animationProgress]);
|
||
|
||
return <div ref={containerRef} className="point-cloud-container" style={{ width: "100%", height: "700px", minHeight: 600 }} />;
|
||
};
|
||
|
||
export default EmbryoGeneration;
|