Add mouse cells

This commit is contained in:
wjsjwr 2025-11-09 22:29:21 +08:00
parent da843ed1c4
commit 699bf4c62e
7 changed files with 511 additions and 323 deletions

BIN
embryo-backend/Data/GSM9046243_Embryo_E7.5_stereo_rep1.h5ad (Stored with Git LFS) Normal file

Binary file not shown.

BIN
embryo-backend/Data/GSM9046244_Embryo_E7.5_stereo_rep2.h5ad (Stored with Git LFS) Normal file

Binary file not shown.

BIN
embryo-backend/Data/GSM9046245_Embryo_E7.75_stereo_rep1.h5ad (Stored with Git LFS) Normal file

Binary file not shown.

BIN
embryo-backend/Data/GSM9046246_Embryo_E7.75_stereo_rep2.h5ad (Stored with Git LFS) Normal file

Binary file not shown.

BIN
embryo-backend/Data/GSM9046247_Embryo_E8.0_stereo_rep1.h5ad (Stored with Git LFS) Normal file

Binary file not shown.

BIN
embryo-backend/Data/GSM9046248_Embryo_E8.0_stereo_rep2.h5ad (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -1,170 +1,110 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useCallback } 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);
interface CameraParameters {
fov: number;
near: number;
far: number;
position: THREE.Vector3;
lookAt: THREE.Vector3;
}
interface LayoutConfig {
leftOffsetX: number;
rightOffsetX: number;
targetOffsetX?: number;
cameraParams: CameraParameters;
transFunc?: (a: Point) => Point;
}
interface AnimationSectionConfig {
id: string;
title: string;
description: string;
stages: {
sourceLeft: string;
sourceRight: string;
target: string;
};
layout: LayoutConfig;
}
const ANIMATION_SECTIONS: AnimationSectionConfig[] = [
{
id: "human-cs",
title: "Human Carnegie stages",
description: "CS7 and CS8 point clouds converge into CS7.5 to highlight human embryonic development.",
stages: {
sourceLeft: "CS7",
sourceRight: "CS8",
target: "CS7.5"
},
layout: {
leftOffsetX: -0.5,
rightOffsetX: 0.5,
targetOffsetX: 0,
cameraParams: {
fov: 30,
near: 0.1,
far: 1000,
position: new THREE.Vector3(0, 0, 30),
lookAt: new THREE.Vector3(0, 0, 0)
}
};
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);
},
{
id: "mouse-early",
title: "Mouse early gastrulation",
description: "Mouse 7.5 and 7.75 datasets merge toward stage 8.5, mirroring the human pipeline.",
stages: {
// sourceLeft: "GSM9046243_Embryo_E7.5_stereo_rep1",
// sourceRight: "GSM9046247_Embryo_E8.0_stereo_rep1",
// target: "GSM9046245_Embryo_E7.75_stereo_rep1"
sourceLeft: "GSM9046244_Embryo_E7.5_stereo_rep2",
sourceRight: "GSM9046248_Embryo_E8.0_stereo_rep2",
target: "GSM9046246_Embryo_E7.75_stereo_rep2"
},
layout: {
leftOffsetX: -0.3,
rightOffsetX: 0.3,
targetOffsetX: -0.05,
cameraParams: {
fov: 30,
near: 0.1,
far: 1000,
position: new THREE.Vector3(-10, 0, 30),
lookAt: new THREE.Vector3(0, 0, 0)
},
transFunc: (p: Point) => {
return {
x: p.x / 50,
y: p.y / 50,
z: p.z / 60,
value: p.value
};
}
};
animate();
}, [isAnimating, cs7Data.length, cs8Data.length, cs75Data.length, animationSpeed]);
const startAnimation = () => {
setIsAnimating(true);
setAnimationProgress(0);
};
const resetAnimation = () => {
setIsAnimating(false);
setAnimationProgress(0);
};
}
}
];
const EmbryoGeneration: React.FC = () => {
return (
<div className="page-container">
<div className="page-header">
<h1>Embryo Generation</h1>
<p className="page-description">
Observe cell migration and merging during embryonic development. CS7, CS8, and CS7.5 point clouds are shown in one 3D view, with an animation simulating cells converging from both sides.
Observe cell migration and merging across human CS7/CS8/CS7.5 and mouse 7.5/7.75/8.5 datasets with the same control workflow.
</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 ? "Animating..." : "Start animation"}
</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
}}
>
Reset
</button>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
Animation speed:
<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" }}>
Progress: {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}
/>
{ANIMATION_SECTIONS.map((section) => (
<EmbryoAnimationSection key={section.id} config={section} />
))}
<div className="section">
<h2>User Guide</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "2rem", marginTop: "1.5rem" }}>
@ -191,21 +131,214 @@ const EmbryoGeneration: React.FC = () => {
);
};
interface UnifiedEmbryoAnimationProps {
cs7Data: Point[];
cs8Data: Point[];
cs75Data: Point[];
animationProgress: number;
interface EmbryoAnimationSectionProps {
config: AnimationSectionConfig;
}
const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data, cs8Data, cs75Data, animationProgress }) => {
const EmbryoAnimationSection: React.FC<EmbryoAnimationSectionProps> = ({ config }) => {
const { stages, title, description, layout } = config;
const { sourceLeft, sourceRight, target } = stages;
const [sourceLeftData, setSourceLeftData] = useState<Point[]>([]);
const [sourceRightData, setSourceRightData] = useState<Point[]>([]);
const [targetData, setTargetData] = 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 [leftRes, rightRes, targetRes] = await Promise.all([
axios.get("http://localhost:5000/api/cell", { params: { stage: sourceLeft } }),
axios.get("http://localhost:5000/api/cell", { params: { stage: sourceRight } }),
axios.get("http://localhost:5000/api/cell", { params: { stage: target } })
]);
setSourceLeftData(leftRes.data.cells || []);
setSourceRightData(rightRes.data.cells || []);
setTargetData(targetRes.data.cells || []);
setAnimationProgress(0);
setIsAnimating(false);
} 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();
}, [sourceLeft, sourceRight, target]);
useEffect(() => {
if (!isAnimating || sourceLeftData.length === 0 || sourceRightData.length === 0 || targetData.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, sourceLeftData.length, sourceRightData.length, targetData.length, animationSpeed]);
const startAnimation = () => {
setIsAnimating(true);
setAnimationProgress(0);
};
const resetAnimation = () => {
setIsAnimating(false);
setAnimationProgress(0);
};
const datasetLabel = `${sourceLeft} + ${sourceRight} -> ${target}`;
const progressPercent = Math.round(animationProgress * 100);
return (
<section className="section" style={{ marginBottom: "3rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h2 style={{ marginBottom: "0.5rem" }}>{title}</h2>
<p className="page-description" style={{ marginBottom: "0.25rem" }}>{description}</p>
<p style={{ color: "var(--text-secondary)", margin: 0 }}>Dataset: {datasetLabel}</p>
</div>
<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 ? "Animating..." : "Start animation"}
</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
}}
>
Reset
</button>
<label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
Animation speed:
<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" }}>
Progress: {progressPercent}%
<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
sourceLeftData={sourceLeftData}
sourceRightData={sourceRightData}
targetData={targetData}
animationProgress={animationProgress}
layout={layout}
/>
</section>
);
};
interface UnifiedEmbryoAnimationProps {
sourceLeftData: Point[];
sourceRightData: Point[];
targetData: Point[];
animationProgress: number;
layout: LayoutConfig;
}
interface TargetAnimationState {
positionsAttr: THREE.BufferAttribute;
alphaAttr: THREE.BufferAttribute;
fromPositions: Float32Array;
toPositions: Float32Array;
delays: Float32Array;
moveDuration: number;
fadeInDuration: number;
}
const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ sourceLeftData, sourceRightData, targetData, animationProgress, layout }) => {
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 animationStateRef = useRef<TargetAnimationState | null>(null);
const { leftOffsetX, rightOffsetX, targetOffsetX = 0, cameraParams, transFunc = null } = layout;
if (transFunc) {
for (let i = 0; i < sourceLeftData.length; i++) {
if (i < 5) console.log(`Source Left Point ${i}: original (${sourceLeftData[i].x.toFixed(2)}, ${sourceLeftData[i].y.toFixed(2)}, ${sourceLeftData[i].z.toFixed(2)})`);
sourceLeftData[i] = transFunc(sourceLeftData[i]);
}
for (let i = 0; i < sourceRightData.length; i++) {
sourceRightData[i] = transFunc(sourceRightData[i]);
}
for (let i = 0; i < targetData.length; i++) {
targetData[i] = transFunc(targetData[i]);
}
}
// 颜色映射
const getColorMap = (allData: Point[]) => {
const cellTypes = Array.from(new Set(allData.map(p => p.value as string))).sort();
const colors = [
@ -221,9 +354,37 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
return colorMap;
};
const applyAnimationProgress = useCallback((progress: number) => {
const animationState = animationStateRef.current;
if (!animationState) return;
const { positionsAttr, alphaAttr, fromPositions, toPositions, delays, moveDuration, fadeInDuration } = animationState;
const positionsArray = positionsAttr.array as Float32Array;
const alphaArray = alphaAttr.array as Float32Array;
const total = delays.length;
for (let i = 0; i < total; i++) {
const delay = delays[i];
let pointProgress = (progress - delay) / moveDuration;
pointProgress = Math.max(0, Math.min(1, pointProgress));
const easeProgress = pointProgress < 0.5
? 2 * pointProgress * pointProgress
: 1 - Math.pow(-2 * pointProgress + 2, 2) / 2;
const baseIndex = i * 3;
positionsArray[baseIndex] = fromPositions[baseIndex] + (toPositions[baseIndex] - fromPositions[baseIndex]) * easeProgress;
positionsArray[baseIndex + 1] = fromPositions[baseIndex + 1] + (toPositions[baseIndex + 1] - fromPositions[baseIndex + 1]) * easeProgress;
positionsArray[baseIndex + 2] = fromPositions[baseIndex + 2] + (toPositions[baseIndex + 2] - fromPositions[baseIndex + 2]) * easeProgress;
const fadeProgress = Math.min(pointProgress / fadeInDuration, 1);
alphaArray[i] = fadeProgress;
}
positionsAttr.needsUpdate = true;
alphaAttr.needsUpdate = true;
}, []);
useEffect(() => {
if (!containerRef.current) return;
// 清理
animationStateRef.current = null;
if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
@ -232,16 +393,22 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
rendererRef.current.dispose();
rendererRef.current = null;
}
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
if (controlsRef.current) {
controlsRef.current.dispose();
controlsRef.current = null;
}
// 初始化
if (containerRef.current) {
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);
const camera = new THREE.PerspectiveCamera(cameraParams.fov, width / height, cameraParams.near, cameraParams.far);
camera.position.set(cameraParams.position.x, cameraParams.position.y, cameraParams.position.z);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
@ -250,42 +417,28 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controlsRef.current = controls;
// 不添加坐标轴
// 颜色映射
const allData = [...cs7Data, ...cs8Data, ...cs75Data];
const disposableMaterials: THREE.Material[] = [];
const disposableGeometries: THREE.BufferGeometry[] = [];
const disposableTextures: THREE.Texture[] = [];
const pointTexture = createTexture();
disposableTextures.push(pointTexture);
const allData = [...sourceLeftData, ...sourceRightData, ...targetData];
const colorMap = getColorMap(allData);
const allPositions: number[] = [];
const recordPosition = (x: number, y: number, z: number) => {
allPositions.push(x, y, z);
};
// 计算所有点的包围盒,自动居中和缩放
let allPositions: number[] = [];
cs7Data.forEach(p => { allPositions.push(p.x - 0.5, p.y, p.z); });
cs8Data.forEach(p => { allPositions.push(p.x + 0.5,p.y, p.z); });
cs75Data.forEach((p, i) => {
const fromX = i % 2 === 0 ? p.x - 0.5 : p.x + 0.5;
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) {
if (sourceLeftData.length > 0) {
const geometry = new THREE.BufferGeometry();
const positions: number[] = [];
const colors: number[] = [];
cs7Data.forEach((p) => {
positions.push(p.x - 0.5, p.y, p.z);
sourceLeftData.forEach((p) => {
const shiftedX = p.x + leftOffsetX;
positions.push(shiftedX, p.y, p.z);
recordPosition(shiftedX, p.y, p.z);
const c = colorMap.get(p.value as string) || new THREE.Color('#CCCCCC');
colors.push(c.r, c.g, c.b);
});
@ -299,35 +452,22 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
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);
})()
map: pointTexture
});
const points = new THREE.Points(geometry, material);
scene.add(points);
disposableGeometries.push(geometry);
disposableMaterials.push(material);
}
// CS8点云静态
if (cs8Data.length > 0) {
if (sourceRightData.length > 0) {
const geometry = new THREE.BufferGeometry();
const positions: number[] = [];
const colors: number[] = [];
cs8Data.forEach((p) => {
positions.push(p.x + 0.5, p.y, p.z);
sourceRightData.forEach((p) => {
const shiftedX = p.x + rightOffsetX;
positions.push(shiftedX, p.y, p.z);
recordPosition(shiftedX, p.y, p.z);
const c = colorMap.get(p.value as string) || new THREE.Color('#CCCCCC');
colors.push(c.r, c.g, c.b);
});
@ -341,122 +481,118 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
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);
})()
map: pointTexture
});
const points = new THREE.Points(geometry, material);
scene.add(points);
disposableGeometries.push(geometry);
disposableMaterials.push(material);
}
// CS7.5点云(动画)
if (cs75Data.length > 0) {
const pickFallbackPoint = (preferred: Point[], alternative: Point[], index: number, defaultPoint: Point): Point => {
if (preferred.length > 0) {
return preferred[index % preferred.length];
}
if (alternative.length > 0) {
return alternative[index % alternative.length];
}
return defaultPoint;
};
if (targetData.length > 0) {
const geometry = new THREE.BufferGeometry();
const positions: number[] = [];
const colors: number[] = [];
const alphas: number[] = [];
const n = cs75Data.length;
const moveDuration = 0.4; // 每个点的动画持续时间(占总进度的比例)
const delayPerPoint = 0.6 / n; // 剩余0.6进度用于错峰
const n = targetData.length;
const positionsArray = new Float32Array(n * 3);
const colorsArray = new Float32Array(n * 3);
const alphasArray = new Float32Array(n);
const fromPositions = new Float32Array(n * 3);
const toPositions = new Float32Array(n * 3);
const delays = new Float32Array(n);
const moveDuration = 0.4;
const delayPerPoint = n > 0 ? 0.6 / n : 0;
const fadeInDuration = 0.3;
// 为CS7.5的点分配起始位置从CS7和CS8的实际数据中获取
// 基于细胞类型或颜色来分配起始位置,确保相同颜色的点从两侧出现
// Left/right source point placeholders removed (unused)
// 按细胞类型分组CS7和CS8数据
const cs7ByType = new Map<string, Point[]>();
const cs8ByType = new Map<string, Point[]>();
cs7Data.forEach(point => {
const sourceLeftByType = new Map<string, Point[]>();
sourceLeftData.forEach(point => {
const type = point.value as string;
if (!cs7ByType.has(type)) cs7ByType.set(type, []);
cs7ByType.get(type)!.push(point);
if (!sourceLeftByType.has(type)) sourceLeftByType.set(type, []);
sourceLeftByType.get(type)!.push(point);
});
cs8Data.forEach(point => {
const sourceRightByType = new Map<string, Point[]>();
sourceRightData.forEach(point => {
const type = point.value as string;
if (!cs8ByType.has(type)) cs8ByType.set(type, []);
cs8ByType.get(type)!.push(point);
if (!sourceRightByType.has(type)) sourceRightByType.set(type, []);
sourceRightByType.get(type)!.push(point);
});
for (let i = 0; i < n; i++) {
const to = cs75Data[i];
const to = targetData[i];
if (i < 5) console.log(`Target Point ${i}: to (${to.x.toFixed(2)}, ${to.y.toFixed(2)}, ${to.z.toFixed(2)})`);
const cellType = to.value as string;
let fromX, fromY, fromZ;
// 基于细胞类型和索引的奇偶性来决定从哪一侧开始
const shouldStartFromLeft = i % 2 === 0;
let sourcePoint: Point;
if (shouldStartFromLeft) {
// 从左侧CS7数据开始
const cs7PointsOfType = cs7ByType.get(cellType) || [];
const sourceIndex = Math.floor(i / 2) % cs7PointsOfType.length;
const sourcePoint = cs7PointsOfType.length > 0
? cs7PointsOfType[sourceIndex]
: cs7Data[i % cs7Data.length];
fromX = sourcePoint.x - 0.5;
fromY = sourcePoint.y;
fromZ = sourcePoint.z;
const pointsOfType = sourceLeftByType.get(cellType) || [];
if (pointsOfType.length > 0) {
const sourceIndex = Math.floor(i / 2) % pointsOfType.length;
sourcePoint = pointsOfType[sourceIndex];
} else {
sourcePoint = pickFallbackPoint(sourceLeftData, sourceRightData, i, to);
}
} else {
// 从右侧CS8数据开始
const cs8PointsOfType = cs8ByType.get(cellType) || [];
const sourceIndex = Math.floor(i / 2) % cs8PointsOfType.length;
const sourcePoint = cs8PointsOfType.length > 0
? cs8PointsOfType[sourceIndex]
: cs8Data[i % cs8Data.length];
fromX = sourcePoint.x + 0.5;
fromY = sourcePoint.y;
fromZ = sourcePoint.z;
const pointsOfType = sourceRightByType.get(cellType) || [];
if (pointsOfType.length > 0) {
const sourceIndex = Math.floor(i / 2) % pointsOfType.length;
sourcePoint = pointsOfType[sourceIndex];
} else {
sourcePoint = pickFallbackPoint(sourceRightData, sourceLeftData, i, to);
}
}
// 添加一些随机延迟,使动画更自然
const offsetX = shouldStartFromLeft ? leftOffsetX : rightOffsetX;
const fromBaseIndex = i * 3;
const fromX = sourcePoint.x + offsetX;
const fromY = sourcePoint.y;
const fromZ = sourcePoint.z;
const toBaseX = to.x + targetOffsetX;
const toBaseY = to.y;
const toBaseZ = to.z;
if (i < 5) {
console.log(`Point ${i}: from (${fromX.toFixed(2)}, ${fromY.toFixed(2)}, ${fromZ.toFixed(2)}) to (${toBaseX.toFixed(2)}, ${toBaseY.toFixed(2)}, ${toBaseZ.toFixed(2)})`);
}
fromPositions[fromBaseIndex] = fromX;
fromPositions[fromBaseIndex + 1] = fromY;
fromPositions[fromBaseIndex + 2] = fromZ;
toPositions[fromBaseIndex] = toBaseX;
toPositions[fromBaseIndex + 1] = toBaseY;
toPositions[fromBaseIndex + 2] = toBaseZ;
positionsArray[fromBaseIndex] = fromX;
positionsArray[fromBaseIndex + 1] = fromY;
positionsArray[fromBaseIndex + 2] = fromZ;
const color = colorMap.get(cellType) || new THREE.Color('#CCCCCC');
colorsArray[fromBaseIndex] = color.r;
colorsArray[fromBaseIndex + 1] = color.g;
colorsArray[fromBaseIndex + 2] = color.b;
alphasArray[i] = 0;
recordPosition(fromX, fromY, fromZ);
recordPosition(toBaseX, toBaseY, toBaseZ);
const baseDelay = i * delayPerPoint;
const randomDelay = (Math.random() - 0.5) * 0.1; // 添加±0.05的随机延迟
const delay = baseDelay + randomDelay;
let pointProgress = (animationProgress - delay) / moveDuration;
pointProgress = Math.max(0, Math.min(1, pointProgress));
// 使用缓动函数使动画更平滑
const easeProgress = pointProgress < 0.5
? 2 * pointProgress * pointProgress
: 1 - Math.pow(-2 * pointProgress + 2, 2) / 2;
const x = fromX + (to.x - fromX) * easeProgress;
const y = fromY + (to.y - fromY) * easeProgress;
const z = fromZ + (to.z - fromZ) * easeProgress;
// 计算透明度:从透明(0)到不透明(1)
// 透明度在动画的前30%时间内从0过渡到1
const fadeInDuration = 0.3;
const fadeProgress = Math.min(pointProgress / fadeInDuration, 1);
const alpha = fadeProgress;
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);
alphas.push(alpha);
const randomDelay = (Math.random() - 0.5) * 0.1;
delays[i] = baseDelay + randomDelay;
}
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
geometry.setAttribute("alpha", new THREE.Float32BufferAttribute(alphas, 1));
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positionsArray, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colorsArray, 3));
geometry.setAttribute("alpha", new THREE.Float32BufferAttribute(alphasArray, 1));
// 创建自定义着色器材质来处理透明度
const vertexShader = `
attribute float alpha;
attribute vec3 color;
@ -475,59 +611,94 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
varying float vAlpha;
varying vec3 vColor;
void main() {
// 使用纹理贴图
vec4 texColor = texture2D(pointTexture, gl_PointCoord);
// 计算当前片段在点内的相对位置
vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center);
// 创建圆形遮罩距离中心0.5半径内的片段可见
if (dist > 0.5) {
discard; // 丢弃圆形外的片段
discard;
}
// 将纹理颜色与顶点颜色混合
gl_FragColor = vec4(vColor * texColor.rgb, vAlpha * texColor.a);
}
`;
const texture = createTexture(); // 创建纹理贴图与PointsMaterial相同
const material = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
vertexShader,
fragmentShader,
transparent: true,
depthWrite: false,
blending: THREE.NormalBlending,
uniforms: {
pointTexture: { value: texture }
pointTexture: { value: pointTexture }
}
});
const points = new THREE.Points(geometry, material);
scene.add(points);
disposableGeometries.push(geometry);
disposableMaterials.push(material);
animationStateRef.current = {
positionsAttr: geometry.getAttribute("position") as THREE.BufferAttribute,
alphaAttr: geometry.getAttribute("alpha") as THREE.BufferAttribute,
fromPositions,
toPositions,
delays,
moveDuration,
fadeInDuration
};
} else {
animationStateRef.current = null;
}
// 渲染循环
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);
const maxDim = Math.max(size.x, size.y, size.z);
const fitScale = maxDim === 0 ? 1 : 32 / maxDim;
scene.position.set(-center.x, -center.y, -center.z);
scene.scale.set(fitScale, fitScale, fitScale);
}
const animate = () => {
animationIdRef.current = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// 清理
applyAnimationProgress(animationProgress);
return () => {
if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
if (controlsRef.current) {
controlsRef.current.dispose();
controlsRef.current = null;
}
if (rendererRef.current) {
rendererRef.current.dispose();
rendererRef.current = null;
}
disposableMaterials.forEach((material) => material.dispose());
disposableGeometries.forEach((geometry) => geometry.dispose());
disposableTextures.forEach((texture) => texture.dispose());
if (containerRef.current) {
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
}
}
animationStateRef.current = null;
};
}, [cs7Data, cs8Data, cs75Data, animationProgress]);
}, [sourceLeftData, sourceRightData, targetData, leftOffsetX, rightOffsetX, targetOffsetX, applyAnimationProgress]);
useEffect(() => {
applyAnimationProgress(animationProgress);
}, [animationProgress, applyAnimationProgress]);
return <div ref={containerRef} className="point-cloud-container" style={{ width: "100%", height: "700px", minHeight: 600 }} />;
};
@ -551,5 +722,4 @@ const createTexture = () => {
ctx.fill();
return new THREE.CanvasTexture(canvas);
};
export default EmbryoGeneration;
export default EmbryoGeneration;