新增查询控制器样式,优化基因表达查询界面,支持自动旋转功能,提升用户交互体验。

This commit is contained in:
wjsjwr 2025-07-26 15:31:10 +08:00
parent 1514130adc
commit 93e0eae353
3 changed files with 324 additions and 82 deletions

View File

@ -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<PointCloudProps> = ({ data, isCategorical = false }) => {
const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, autoRotate = false }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | undefined>(undefined);
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[]) => {
@ -39,8 +48,32 @@ const PointCloud: React.FC<PointCloudProps> = ({ 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<PointCloudProps> = ({ 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<PointCloudProps> = ({ 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<PointCloudProps> = ({ 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<PointCloudProps> = ({ 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<PointCloudProps> = ({ 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 <div ref={containerRef} className="point-cloud-container" />;
// 监听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 (
<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;

View File

@ -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 = () => {
</div>
<div className="page-content">
<div className="section">
<h2></h2>
{/* 阶段选择 */}
<div className="stage-selector">
<label>
:&nbsp;
<select
value={selectedStage}
onChange={(e) => {
setSelectedStage(e.target.value);
setGene("");
setData([]);
setError("");
}}
>
<option value="CS7">CS7 - </option>
<option value="CS8">CS8 - </option>
<option value="CS9">CS9 - </option>
</select>
</label>
</div>
{/* 基因选择 + 按钮 */}
<div className="gene-selector">
<label htmlFor="gene-select" className="gene-label">:</label>
{/* 基因表达查询控制器 */}
<div className="query-controls" style={{
display: "flex",
alignItems: "center",
gap: "2rem",
flexWrap: "wrap",
marginBottom: "2rem"
}}>
<label>
:&nbsp;
<select
id="gene-select"
aria-label="选择基因"
value={gene}
onChange={(e) => setGene(e.target.value)}
className="gene-select"
value={selectedStage}
onChange={(e) => {
setSelectedStage(e.target.value);
setGene("");
setData([]);
setError("");
}}
>
<option value="">-- --</option>
{availableGenes.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
<option value="CS7">CS7 - </option>
<option value="CS8">CS8 - </option>
<option value="CS9">CS9 - </option>
</select>
<button onClick={handleSearch} disabled={!gene || loading}>
{loading ? "加载中..." : "查询表达"}
</button>
</div>
</label>
{/* 错误提示 */}
{error && <div className="error-message">{error}</div>}
<label htmlFor="gene-select">:</label>
<select
id="gene-select"
aria-label="选择基因"
value={gene}
onChange={(e) => setGene(e.target.value)}
>
<option value="">-- --</option>
{availableGenes.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<button onClick={handleSearch} disabled={!gene || loading}>
{loading ? "加载中..." : "查询表达"}
</button>
</div>
<div className="section">
<h2>3D </h2>
<p style={{ marginBottom: "1.5rem", color: "var(--text-secondary)" }}>
{selectedStage}
</p>
{/* 三可视化区域 */}
<div
className="three-column-grid"
style={{
display: "grid",
gridTemplateColumns: "minmax(400px, 1fr) minmax(400px, 1fr) minmax(450px, 1fr)",
gap: "2rem",
minHeight: "600px",
width: "100%"
}}
>
{/* 错误提示 */}
{error && <div className="error-message" style={{ marginBottom: "2rem" }}>{error}</div>}
{/* 3D 可视化说明 */}
<p className="flat-section-description">
{selectedStage}
</p>
{/* 三可视化区域 */}
<div
className="three-column-grid visualization-grid"
style={{
display: "grid",
gridTemplateColumns: "minmax(400px, 1fr) minmax(400px, 1fr) minmax(450px, 1fr)",
gap: "2rem",
minHeight: "600px",
width: "100%"
}}
>
{/* 细胞类型可视化 */}
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
@ -243,7 +241,7 @@ const GeneView: React.FC = () => {
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
{data.length > 0 ? (
<PointCloud data={data} />
<PointCloud data={data} autoRotate={true} />
) : (
<div className="no-data-message">
{loading ? (
@ -344,7 +342,6 @@ const GeneView: React.FC = () => {
)}
</div>
</div>
</div>
<div className="section">
<h2>使</h2>

View File

@ -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;
}