新增查询控制器样式,优化基因表达查询界面,支持自动旋转功能,提升用户交互体验。
This commit is contained in:
parent
1514130adc
commit
93e0eae353
@ -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;
|
||||
|
||||
@ -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>
|
||||
发育阶段:
|
||||
<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>
|
||||
发育阶段:
|
||||
<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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user