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 axios from "axios";
import * as THREE from "three"; import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import "../style.css"; import "../style.css";
interface Point { interface Point {
x: number; x: number;
y: number; y: number;
z: number; z: number;
value: number | string; value: number | string;
} }
interface CameraParameters {
const EmbryoGeneration: React.FC = () => { fov: number;
const [cs7Data, setCs7Data] = useState<Point[]>([]); near: number;
const [cs8Data, setCs8Data] = useState<Point[]>([]); far: number;
const [cs75Data, setCs75Data] = useState<Point[]>([]); position: THREE.Vector3;
const [loading, setLoading] = useState(true); lookAt: THREE.Vector3;
const [error, setError] = useState(""); }
const [animationProgress, setAnimationProgress] = useState(0); interface LayoutConfig {
const [isAnimating, setIsAnimating] = useState(false); leftOffsetX: number;
const [animationSpeed, setAnimationSpeed] = useState(2); rightOffsetX: number;
targetOffsetX?: number;
// 加载数据 cameraParams: CameraParameters;
useEffect(() => { transFunc?: (a: Point) => Point;
const loadData = async () => { }
setLoading(true); interface AnimationSectionConfig {
setError(""); id: string;
try { title: string;
const [cs7Res, cs8Res, cs75Res] = await Promise.all([ description: string;
axios.get("http://localhost:5000/api/cell", { params: { stage: "CS7" } }), stages: {
axios.get("http://localhost:5000/api/cell", { params: { stage: "CS8" } }), sourceLeft: string;
axios.get("http://localhost:5000/api/cell", { params: { stage: "CS7.5" } }) sourceRight: string;
]); target: string;
setCs7Data(cs7Res.data.cells); };
setCs8Data(cs8Res.data.cells); layout: LayoutConfig;
setCs75Data(cs75Res.data.cells); }
} catch (err: any) { const ANIMATION_SECTIONS: AnimationSectionConfig[] = [
if (err.response && err.response.data?.error) { {
setError(err.response.data.error); id: "human-cs",
} else { title: "Human Carnegie stages",
setError("Failed to fetch embryo data."); description: "CS7 and CS8 point clouds converge into CS7.5 to highlight human embryonic development.",
} stages: {
} finally { sourceLeft: "CS7",
setLoading(false); 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 = () => { id: "mouse-early",
const elapsed = Date.now() - startTime; title: "Mouse early gastrulation",
const progress = Math.min(elapsed / duration, 1); description: "Mouse 7.5 and 7.75 datasets merge toward stage 8.5, mirroring the human pipeline.",
setAnimationProgress(progress); stages: {
if (progress < 1) { // sourceLeft: "GSM9046243_Embryo_E7.5_stereo_rep1",
requestAnimationFrame(animate); // sourceRight: "GSM9046247_Embryo_E8.0_stereo_rep1",
} else { // target: "GSM9046245_Embryo_E7.75_stereo_rep1"
setIsAnimating(false); sourceLeft: "GSM9046244_Embryo_E7.5_stereo_rep2",
setAnimationProgress(1); 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 EmbryoGeneration: React.FC = () => {
const startAnimation = () => {
setIsAnimating(true);
setAnimationProgress(0);
};
const resetAnimation = () => {
setIsAnimating(false);
setAnimationProgress(0);
};
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header"> <div className="page-header">
<h1>Embryo Generation</h1> <h1>Embryo Generation</h1>
<p className="page-description"> <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> </p>
</div> </div>
<div className="page-content"> <div className="page-content">
<div className="control-panel" style={{ {ANIMATION_SECTIONS.map((section) => (
display: "flex", <EmbryoAnimationSection key={section.id} config={section} />
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}
/>
<div className="section"> <div className="section">
<h2>User Guide</h2> <h2>User Guide</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "2rem", marginTop: "1.5rem" }}> <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 { interface EmbryoAnimationSectionProps {
cs7Data: Point[]; config: AnimationSectionConfig;
cs8Data: Point[];
cs75Data: Point[];
animationProgress: number;
} }
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 containerRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null); const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null); const sceneRef = useRef<THREE.Scene | null>(null);
const controlsRef = useRef<any>(null); const controlsRef = useRef<any>(null);
const animationIdRef = useRef<number | null>(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 getColorMap = (allData: Point[]) => {
const cellTypes = Array.from(new Set(allData.map(p => p.value as string))).sort(); const cellTypes = Array.from(new Set(allData.map(p => p.value as string))).sort();
const colors = [ const colors = [
@ -221,9 +354,37 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
return colorMap; 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(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
// 清理
animationStateRef.current = null;
if (animationIdRef.current !== null) { if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current); cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null; animationIdRef.current = null;
@ -232,16 +393,22 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
rendererRef.current.dispose(); rendererRef.current.dispose();
rendererRef.current = null; rendererRef.current = null;
} }
while (containerRef.current.firstChild) { if (controlsRef.current) {
containerRef.current.removeChild(containerRef.current.firstChild); 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 width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight; const height = containerRef.current.clientHeight;
const scene = new THREE.Scene(); const scene = new THREE.Scene();
sceneRef.current = scene; sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(30, width / height, 0.1, 1000); const camera = new THREE.PerspectiveCamera(cameraParams.fov, width / height, cameraParams.near, cameraParams.far);
camera.position.set(0, 0, 30); camera.position.set(cameraParams.position.x, cameraParams.position.y, cameraParams.position.z);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true }); const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height); renderer.setSize(width, height);
@ -250,42 +417,28 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
const controls = new OrbitControls(camera, renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
controlsRef.current = controls; controlsRef.current = controls;
// 不添加坐标轴
// 颜色映射 const disposableMaterials: THREE.Material[] = [];
const allData = [...cs7Data, ...cs8Data, ...cs75Data]; const disposableGeometries: THREE.BufferGeometry[] = [];
const disposableTextures: THREE.Texture[] = [];
const pointTexture = createTexture();
disposableTextures.push(pointTexture);
const allData = [...sourceLeftData, ...sourceRightData, ...targetData];
const colorMap = getColorMap(allData); const colorMap = getColorMap(allData);
const allPositions: number[] = [];
const recordPosition = (x: number, y: number, z: number) => {
allPositions.push(x, y, z);
};
// 计算所有点的包围盒,自动居中和缩放 if (sourceLeftData.length > 0) {
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) {
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
const positions: number[] = []; const positions: number[] = [];
const colors: number[] = []; const colors: number[] = [];
cs7Data.forEach((p) => { sourceLeftData.forEach((p) => {
positions.push(p.x - 0.5, p.y, p.z); 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'); const c = colorMap.get(p.value as string) || new THREE.Color('#CCCCCC');
colors.push(c.r, c.g, c.b); colors.push(c.r, c.g, c.b);
}); });
@ -299,35 +452,22 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
alphaTest: 0.5, alphaTest: 0.5,
depthWrite: false, depthWrite: false,
blending: THREE.NormalBlending, blending: THREE.NormalBlending,
map: (() => { map: pointTexture
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); const points = new THREE.Points(geometry, material);
scene.add(points); scene.add(points);
disposableGeometries.push(geometry);
disposableMaterials.push(material);
} }
// CS8点云静态
if (cs8Data.length > 0) { if (sourceRightData.length > 0) {
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
const positions: number[] = []; const positions: number[] = [];
const colors: number[] = []; const colors: number[] = [];
cs8Data.forEach((p) => { sourceRightData.forEach((p) => {
positions.push(p.x + 0.5, p.y, p.z); 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'); const c = colorMap.get(p.value as string) || new THREE.Color('#CCCCCC');
colors.push(c.r, c.g, c.b); colors.push(c.r, c.g, c.b);
}); });
@ -341,122 +481,118 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
alphaTest: 0.5, alphaTest: 0.5,
depthWrite: false, depthWrite: false,
blending: THREE.NormalBlending, blending: THREE.NormalBlending,
map: (() => { map: pointTexture
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); const points = new THREE.Points(geometry, material);
scene.add(points); 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 geometry = new THREE.BufferGeometry();
const positions: number[] = []; const n = targetData.length;
const colors: number[] = []; const positionsArray = new Float32Array(n * 3);
const alphas: number[] = []; const colorsArray = new Float32Array(n * 3);
const n = cs75Data.length; const alphasArray = new Float32Array(n);
const moveDuration = 0.4; // 每个点的动画持续时间(占总进度的比例) const fromPositions = new Float32Array(n * 3);
const delayPerPoint = 0.6 / n; // 剩余0.6进度用于错峰 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的实际数据中获取 const sourceLeftByType = new Map<string, Point[]>();
// 基于细胞类型或颜色来分配起始位置,确保相同颜色的点从两侧出现 sourceLeftData.forEach(point => {
// 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 type = point.value as string; const type = point.value as string;
if (!cs7ByType.has(type)) cs7ByType.set(type, []); if (!sourceLeftByType.has(type)) sourceLeftByType.set(type, []);
cs7ByType.get(type)!.push(point); sourceLeftByType.get(type)!.push(point);
}); });
cs8Data.forEach(point => { const sourceRightByType = new Map<string, Point[]>();
sourceRightData.forEach(point => {
const type = point.value as string; const type = point.value as string;
if (!cs8ByType.has(type)) cs8ByType.set(type, []); if (!sourceRightByType.has(type)) sourceRightByType.set(type, []);
cs8ByType.get(type)!.push(point); sourceRightByType.get(type)!.push(point);
}); });
for (let i = 0; i < n; i++) { 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; const cellType = to.value as string;
let fromX, fromY, fromZ;
// 基于细胞类型和索引的奇偶性来决定从哪一侧开始
const shouldStartFromLeft = i % 2 === 0; const shouldStartFromLeft = i % 2 === 0;
let sourcePoint: Point;
if (shouldStartFromLeft) { if (shouldStartFromLeft) {
// 从左侧CS7数据开始 const pointsOfType = sourceLeftByType.get(cellType) || [];
const cs7PointsOfType = cs7ByType.get(cellType) || []; if (pointsOfType.length > 0) {
const sourceIndex = Math.floor(i / 2) % cs7PointsOfType.length; const sourceIndex = Math.floor(i / 2) % pointsOfType.length;
const sourcePoint = cs7PointsOfType.length > 0 sourcePoint = pointsOfType[sourceIndex];
? cs7PointsOfType[sourceIndex] } else {
: cs7Data[i % cs7Data.length]; sourcePoint = pickFallbackPoint(sourceLeftData, sourceRightData, i, to);
fromX = sourcePoint.x - 0.5; }
fromY = sourcePoint.y;
fromZ = sourcePoint.z;
} else { } else {
// 从右侧CS8数据开始 const pointsOfType = sourceRightByType.get(cellType) || [];
const cs8PointsOfType = cs8ByType.get(cellType) || []; if (pointsOfType.length > 0) {
const sourceIndex = Math.floor(i / 2) % cs8PointsOfType.length; const sourceIndex = Math.floor(i / 2) % pointsOfType.length;
const sourcePoint = cs8PointsOfType.length > 0 sourcePoint = pointsOfType[sourceIndex];
? cs8PointsOfType[sourceIndex] } else {
: cs8Data[i % cs8Data.length]; sourcePoint = pickFallbackPoint(sourceRightData, sourceLeftData, i, to);
fromX = sourcePoint.x + 0.5; }
fromY = sourcePoint.y;
fromZ = sourcePoint.z;
} }
// 添加一些随机延迟,使动画更自然 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 baseDelay = i * delayPerPoint;
const randomDelay = (Math.random() - 0.5) * 0.1; // 添加±0.05的随机延迟 const randomDelay = (Math.random() - 0.5) * 0.1;
const delay = baseDelay + randomDelay; delays[i] = 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);
} }
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute("position", new THREE.Float32BufferAttribute(positionsArray, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); geometry.setAttribute("color", new THREE.Float32BufferAttribute(colorsArray, 3));
geometry.setAttribute("alpha", new THREE.Float32BufferAttribute(alphas, 1)); geometry.setAttribute("alpha", new THREE.Float32BufferAttribute(alphasArray, 1));
// 创建自定义着色器材质来处理透明度
const vertexShader = ` const vertexShader = `
attribute float alpha; attribute float alpha;
attribute vec3 color; attribute vec3 color;
@ -475,59 +611,94 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
varying float vAlpha; varying float vAlpha;
varying vec3 vColor; varying vec3 vColor;
void main() { void main() {
// 使用纹理贴图
vec4 texColor = texture2D(pointTexture, gl_PointCoord); vec4 texColor = texture2D(pointTexture, gl_PointCoord);
// 计算当前片段在点内的相对位置
vec2 center = gl_PointCoord - vec2(0.5); vec2 center = gl_PointCoord - vec2(0.5);
float dist = length(center); float dist = length(center);
// 创建圆形遮罩距离中心0.5半径内的片段可见
if (dist > 0.5) { if (dist > 0.5) {
discard; // 丢弃圆形外的片段 discard;
} }
// 将纹理颜色与顶点颜色混合
gl_FragColor = vec4(vColor * texColor.rgb, vAlpha * texColor.a); gl_FragColor = vec4(vColor * texColor.rgb, vAlpha * texColor.a);
} }
`; `;
const texture = createTexture(); // 创建纹理贴图与PointsMaterial相同
const material = new THREE.ShaderMaterial({ const material = new THREE.ShaderMaterial({
vertexShader: vertexShader, vertexShader,
fragmentShader: fragmentShader, fragmentShader,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
blending: THREE.NormalBlending, blending: THREE.NormalBlending,
uniforms: { uniforms: {
pointTexture: { value: texture } pointTexture: { value: pointTexture }
} }
}); });
const points = new THREE.Points(geometry, material); const points = new THREE.Points(geometry, material);
scene.add(points); 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 = () => { const animate = () => {
animationIdRef.current = requestAnimationFrame(animate); animationIdRef.current = requestAnimationFrame(animate);
controls.update(); controls.update();
renderer.render(scene, camera); renderer.render(scene, camera);
}; };
animate(); animate();
// 清理
applyAnimationProgress(animationProgress);
return () => { return () => {
if (animationIdRef.current !== null) { if (animationIdRef.current !== null) {
cancelAnimationFrame(animationIdRef.current); cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
} }
if (controlsRef.current) { if (controlsRef.current) {
controlsRef.current.dispose(); controlsRef.current.dispose();
controlsRef.current = null;
} }
if (rendererRef.current) { if (rendererRef.current) {
rendererRef.current.dispose(); 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 }} />; return <div ref={containerRef} className="point-cloud-container" style={{ width: "100%", height: "700px", minHeight: 600 }} />;
}; };
@ -551,5 +722,4 @@ const createTexture = () => {
ctx.fill(); ctx.fill();
return new THREE.CanvasTexture(canvas); return new THREE.CanvasTexture(canvas);
}; };
export default EmbryoGeneration; export default EmbryoGeneration;