From 93e0eae3532130d9615aa71f26c1fdf918ad7616 Mon Sep 17 00:00:00 2001 From: wjsjwr Date: Sat, 26 Jul 2025 15:31:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9F=A5=E8=AF=A2=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8=E6=A0=B7=E5=BC=8F=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=9F=BA=E5=9B=A0=E8=A1=A8=E8=BE=BE=E6=9F=A5=E8=AF=A2=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8=E6=97=8B?= =?UTF-8?q?=E8=BD=AC=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=8F=90=E5=8D=87=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- embryo-frontend/src/components/PointCloud.tsx | 178 ++++++++++++++++-- embryo-frontend/src/pages/GeneView.tsx | 137 +++++++------- embryo-frontend/src/style.css | 91 +++++++++ 3 files changed, 324 insertions(+), 82 deletions(-) diff --git a/embryo-frontend/src/components/PointCloud.tsx b/embryo-frontend/src/components/PointCloud.tsx index f842c1e..2e9650b 100644 --- a/embryo-frontend/src/components/PointCloud.tsx +++ b/embryo-frontend/src/components/PointCloud.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; @@ -12,11 +12,20 @@ interface Point { interface PointCloudProps { data: Point[]; isCategorical?: boolean; // 是否为分类数据(细胞类型) + autoRotate?: boolean; // 是否自动旋转 } -const PointCloud: React.FC = ({ data, isCategorical = false }) => { +const PointCloud: React.FC = ({ data, isCategorical = false, autoRotate = false }) => { const containerRef = useRef(null); - const rendererRef = useRef(undefined); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const geometryRef = useRef(null); + const materialRef = useRef(null); + const animationIdRef = useRef(null); + const controlsRef = useRef(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[]) => { @@ -39,8 +48,32 @@ const PointCloud: React.FC = ({ data, isCategorical = false }) 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; + } + // 清除旧渲染内容 - if (containerRef.current.firstChild) { + while (containerRef.current.firstChild) { containerRef.current.removeChild(containerRef.current.firstChild); } @@ -49,16 +82,20 @@ const PointCloud: React.FC = ({ data, isCategorical = false }) // 初始化场景、相机、渲染器 const scene = new THREE.Scene(); + sceneRef.current = scene; // 存储scene引用 const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000); - camera.position.set(0, 0, 30); + camera.position.set(-15, 15, -12); // 从左下后方观察,使Z轴指向右上方 + camera.lookAt(0, 0, 0); // 确保相机朝向原点 const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(width, height); + rendererRef.current = renderer; containerRef.current.appendChild(renderer.domElement); // 控件 const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; + controlsRef.current = controls; // 坐标轴辅助 const axesHelper = new THREE.AxesHelper(10); @@ -66,6 +103,7 @@ const PointCloud: React.FC = ({ data, isCategorical = false }) // 点云数据 const geometry = new THREE.BufferGeometry(); + geometryRef.current = geometry; const positions: number[] = []; const colors: number[] = []; const sizes: number[] = []; @@ -111,6 +149,7 @@ const PointCloud: React.FC = ({ data, isCategorical = false }) size: 0.5, sizeAttenuation: true, }); + materialRef.current = material; const points = new THREE.Points(geometry, material); scene.add(points); @@ -144,7 +183,13 @@ const PointCloud: React.FC = ({ data, isCategorical = false }) // 渲染循环 const animate = () => { - requestAnimationFrame(animate); + animationIdRef.current = requestAnimationFrame(animate); + + // 自动旋转逻辑 - 使用 ref 来避免重新创建场景 + if (isSpinningRef.current && autoRotate) { + scene.rotation.z += 0.002; // 围绕Z轴更慢速旋转 + } + controls.update(); renderer.render(scene, camera); }; @@ -182,14 +227,123 @@ const PointCloud: React.FC = ({ data, isCategorical = false }) // 清理 return () => { - renderer.dispose(); - geometry.dispose(); - material.dispose(); - renderer.domElement.removeEventListener("click", onClick); + // 停止动画循环 + 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]); + }, [data, isCategorical, autoRotate]); - return
; + // 监听autoRotate prop变化,重置旋转状态 + useEffect(() => { + setIsSpinning(autoRotate); + isSpinningRef.current = autoRotate; + }, [autoRotate]); + + // 监听isSpinning状态变化,只更新ref,不重新创建场景 + useEffect(() => { + isSpinningRef.current = isSpinning; + }, [isSpinning]); + + // 组件卸载时的最终清理 + useEffect(() => { + return () => { + if (animationIdRef.current !== null) { + cancelAnimationFrame(animationIdRef.current); + } + if (controlsRef.current) { + controlsRef.current.dispose(); + } + if (rendererRef.current) { + rendererRef.current.dispose(); + } + if (geometryRef.current) { + geometryRef.current.dispose(); + } + if (materialRef.current) { + materialRef.current.dispose(); + } + }; + }, []); + + return ( +
+
+ {autoRotate === true && ( +
+ setIsSpinning(e.target.checked)} + style={{ + cursor: 'pointer', + width: '16px', + height: '16px', + accentColor: '#4ECDC4' + }} + /> + +
+ )} +
+ ); }; export default PointCloud; diff --git a/embryo-frontend/src/pages/GeneView.tsx b/embryo-frontend/src/pages/GeneView.tsx index 733f8d8..2c596a7 100644 --- a/embryo-frontend/src/pages/GeneView.tsx +++ b/embryo-frontend/src/pages/GeneView.tsx @@ -25,11 +25,11 @@ const GeneView: React.FC = () => { '#F8C471', '#82E0AA', '#F1948A', '#85CDCE', '#D7BDE2', '#A9DFBF', '#F9E79F', '#D5A6BD', '#AED6F1', '#E8DAEF' ]; - + cellTypes.forEach((type, index) => { colorMap.set(type, colors[index % colors.length]); }); - + return colorMap; }; @@ -47,7 +47,7 @@ const GeneView: React.FC = () => { // 获取细胞类型数据 useEffect(() => { if (!selectedStage) return; - + setCellLoading(true); setCellError(""); setCellData([]); @@ -100,12 +100,12 @@ const GeneView: React.FC = () => { setError("No expression data found."); } else { setData(expressionRes.data.expression); - + // 转换分布数据为 nivo boxplot 格式 - 需要扁平化为原始数据点 const boxplotData: any[] = []; const cellTypes = Object.keys(distributionRes.data.distribution).sort(); const colorMap = generateCellTypeColors(cellTypes); - + Object.entries(distributionRes.data.distribution).forEach( ([cellType, values]: [string, any]) => { values.forEach((value: number) => { @@ -116,13 +116,13 @@ const GeneView: React.FC = () => { }); } ); - + // 创建颜色对象映射 const colorObj: { [key: string]: string } = {}; cellTypes.forEach(cellType => { colorObj[cellType] = colorMap.get(cellType) || '#CCCCCC'; }); - + setDistributionData({ data: boxplotData, colorObj, cellTypes }); } } catch (err: any) { @@ -146,73 +146,71 @@ const GeneView: React.FC = () => {
-
-

基因表达查询

- - {/* 阶段选择 */} -
- -
- - {/* 基因选择 + 按钮 */} -
- + {/* 基因表达查询控制器 */} +
+
+ - {/* 错误提示 */} - {error &&
{error}
} + + + +
-
-

3D 可视化

-

- 下方展示了{selectedStage}发育阶段的细胞类型分布(左)、所选基因的表达模式(中)和基因在各细胞类型中的表达分布(右)。 - 点击任意数据点可查看详细信息。 -

- - {/* 三可视化区域 */} -
+ {/* 错误提示 */} + {error &&
{error}
} + + {/* 3D 可视化说明 */} +

+ 下方展示了{selectedStage}发育阶段的细胞类型分布(左)、所选基因的表达模式(中)和基因在各细胞类型中的表达分布(右)。 + 点击任意数据点可查看详细信息。 +

+ + {/* 三可视化区域 */} +
{/* 细胞类型可视化 */}

细胞类型分布

@@ -243,7 +241,7 @@ const GeneView: React.FC = () => {

基因表达模式

{data.length > 0 ? ( - + ) : (
{loading ? ( @@ -344,7 +342,6 @@ const GeneView: React.FC = () => { )}
-

使用说明

diff --git a/embryo-frontend/src/style.css b/embryo-frontend/src/style.css index 887a383..3f36400 100644 --- a/embryo-frontend/src/style.css +++ b/embryo-frontend/src/style.css @@ -744,6 +744,58 @@ button:disabled { background-color: var(--primary-color); } +/* ===== 查询控制器样式 ===== */ +.query-controls { + padding: 1.5rem; + background-color: var(--background-accent); + border-radius: 0.5rem; + border: 1px solid var(--border-color); +} + +.query-controls label { + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} + +.query-controls select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background-color: var(--background-primary); + color: var(--text-primary); + font-size: 0.875rem; + min-width: 200px; + margin-left: 0.5rem; +} + +.query-controls select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(44, 82, 130, 0.1); +} + +.query-controls button { + padding: 0.5rem 1.5rem; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 0.375rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; + white-space: nowrap; +} + +.query-controls button:hover:not(:disabled) { + background-color: var(--secondary-color); +} + +.query-controls button:disabled { + background-color: var(--text-muted); + cursor: not-allowed; +} + /* ===== 响应式设计 ===== */ @media (max-width: 768px) { .nav-container { @@ -789,6 +841,25 @@ button:disabled { } } +@media (max-width: 768px) { + .query-controls { + flex-direction: column; + align-items: stretch; + gap: 1rem !important; + } + + .query-controls select { + min-width: auto; + width: 100%; + margin-left: 0; + margin-top: 0.5rem; + } + + .query-controls button { + margin-top: 0.5rem; + } +} + /* ===== 响应式网格布局 ===== */ @media (max-width: 1200px) { .three-column-grid { @@ -816,3 +887,23 @@ button:disabled { min-width: auto; } } + +/* ===== 扁平化标题样式 ===== */ +.flat-section-title { + font-size: 1.75rem; + font-weight: 600; + color: var(--primary-color); + margin-bottom: 1rem; + margin-top: 1rem; +} + +.flat-section-description { + margin-bottom: 2rem; + color: var(--text-secondary); + font-size: 1.1rem; + line-height: 1.6; +} + +.visualization-grid { + margin-bottom: 3rem; +}