新增CS11数据文件,优化PointCloud组件样式和图例展示,调整相机设置以改善3D可视化效果,提升用户体验。

This commit is contained in:
wjsjwr 2025-07-27 13:28:05 +08:00
parent 2291f59144
commit 020a3e5cd6
5 changed files with 325 additions and 122 deletions

Binary file not shown.

View File

@ -36,12 +36,12 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
'#F8C471', '#82E0AA', '#F1948A', '#85CDCE', '#D7BDE2',
'#A9DFBF', '#F9E79F', '#D5A6BD', '#AED6F1', '#E8DAEF'
];
cellTypes.forEach((type, index) => {
const colorHex = colors[index % colors.length];
colorMap.set(type, new THREE.Color(colorHex));
});
return colorMap;
};
@ -56,17 +56,17 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
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;
@ -83,12 +83,13 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
// 初始化场景、相机、渲染器
const scene = new THREE.Scene();
sceneRef.current = scene; // 存储scene引用
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
camera.position.set(-15, 15, -12); // 从左下后方观察使Z轴指向右上方
camera.lookAt(0, 0, 0); // 确保相机朝向原点
const camera = new THREE.PerspectiveCamera(600, width / height, 0.1, 1000);
camera.position.set(0, 0, -500); // 从左下后方观察使Z轴指向右上方
camera.lookAt(0, -100, 0); // 确保相机朝向原点
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setClearColor(0xFFFFFF, 1); // 设置背景为透明
rendererRef.current = renderer;
containerRef.current.appendChild(renderer.domElement);
@ -125,7 +126,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
data.forEach((point) => {
positions.push(point.x, point.y, point.z);
if (isCategorical && colorMap) {
// 分类数据:按细胞类型着色
const cellColor = colorMap.get(point.value as string) || new THREE.Color('#CCCCCC');
@ -146,11 +147,32 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
const material = new THREE.PointsMaterial({
vertexColors: true,
size: 0.5,
size: 5,
sizeAttenuation: true,
transparent: true,
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);
})()
});
materialRef.current = material;
const points = new THREE.Points(geometry, material);
scene.add(points);
@ -169,13 +191,13 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
if (intersects.length > 0) {
const i = intersects[0].index ?? 0;
const p = data[i];
console.log(`Clicked: x=${p.x}, y=${p.y}, z=${p.z}, value=${p.value}`);
if (isCategorical) {
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nCell Type: ${p.value}`);
} else {
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nExpression: ${(p.value as number).toFixed(3)}`);
}
// console.log(`Clicked: x=${p.x}, y=${p.y}, z=${p.z}, value=${p.value}`);
// if (isCategorical) {
// alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nCell Type: ${p.value}`);
// } else {
// alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nExpression: ${(p.value as number).toFixed(3)}`);
// }
}
};
@ -184,46 +206,30 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
// 渲染循环
const animate = () => {
animationIdRef.current = requestAnimationFrame(animate);
// 自动旋转逻辑 - 使用 ref 来避免重新创建场景
if (isSpinningRef.current && autoRotate) {
scene.rotation.z += 0.002; // 围绕Z轴更慢速旋转
scene.rotation.y += 0.002; // 围绕Z轴更慢速旋转
}
controls.update();
renderer.render(scene, camera);
};
animate();
// 图例
const legend = document.createElement("div");
legend.className = "point-cloud-legend";
if (isCategorical && colorMap) {
// 分类数据图例
let legendHTML = '<div class="legend-title">细胞类型</div>';
cellTypes.forEach(type => {
const cellColor = colorMap!.get(type);
if (cellColor) {
const hexColor = `#${cellColor.getHexString()}`;
legendHTML += `
<div class="legend-item">
<div class="legend-color" style="background-color: ${hexColor}"></div>
<span class="legend-label">${type}</span>
</div>
`;
}
});
legend.innerHTML = legendHTML;
} else {
// 只在非分类数据时在3D图形内显示图例
if (!isCategorical) {
const legend = document.createElement("div");
legend.className = "point-cloud-legend";
// 数值数据图例
legend.innerHTML = `
<div class="legend-gradient"></div>
<div> High</div>
<div> Low</div>`;
containerRef.current.appendChild(legend);
}
containerRef.current.appendChild(legend);
// 清理
return () => {
@ -232,29 +238,29 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
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;
@ -294,56 +300,150 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
};
}, []);
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>
// 生成图例内容
const renderLegend = () => {
if (isCategorical) {
// 分类数据图例
const cellTypes = Array.from(new Set(data.map(p => p.value as string))).sort();
const colorMap = generateCellTypeColors(cellTypes);
return (
<div>
<div style={{ fontWeight: '600', marginBottom: '10px', fontSize: '14px' }}></div>
<div>
{cellTypes.map(type => {
const cellColor = colorMap.get(type);
if (cellColor) {
const hexColor = `#${cellColor.getHexString()}`;
return (
<div key={type} style={{ display: 'flex', alignItems: 'center', marginBottom: '5px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: hexColor,
marginRight: '8px'
}}
/>
<span style={{ fontSize: '12px' }}>{type}</span>
</div>
);
}
return null;
})}
</div>
</div>
)}
</div>
);
);
} else {
// 数值数据图例
return (
<div>
<div style={{ fontWeight: '600', marginBottom: '10px', fontSize: '14px' }}></div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{
background: 'linear-gradient(to top, hsla(180, 100%, 50%, 1.00), hsla(72, 100%, 50%, 1.00), hsla(0, 100%, 50%, 0.99))',
width: '20px',
height: '80px',
marginBottom: '5px'
}} />
<div style={{ fontSize: '11px', color: '#666' }}>
<div></div>
<div></div>
</div>
</div>
</div>
);
}
};
// 根据isCategorical返回不同的布局
if (isCategorical) {
return (
<div className="point-cloud-visualization-container" style={{
display: 'flex',
width: '100%',
gap: '0',
height: '100%'
}}>
{/* 左侧3D图形 */}
<div style={{
flex: '1',
position: 'relative',
height: '100%',
minHeight: '400px'
}}>
<div ref={containerRef} className="point-cloud-container" style={{ width: '100%', height: '100%' }} />
</div>
{/* 右侧:图例 */}
<div style={{
width: '200px',
padding: '15px',
backgroundColor: 'white',
border: '1px solid #e2e8f0',
height: 'fit-content',
maxHeight: '100%',
overflowY: 'auto',
flexShrink: 0
}}>
{renderLegend()}
</div>
</div>
);
} else {
// 原始单列布局
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

@ -97,8 +97,8 @@ const EmbryoGeneration: React.FC = () => {
borderRadius: "8px",
flexWrap: "wrap"
}}>
<button
onClick={startAnimation}
<button
onClick={startAnimation}
disabled={isAnimating || loading}
style={{
padding: "0.75rem 1.5rem",
@ -112,7 +112,7 @@ const EmbryoGeneration: React.FC = () => {
>
{isAnimating ? "动画中..." : "开始动画"}
</button>
<button
<button
onClick={resetAnimation}
disabled={loading}
style={{
@ -291,8 +291,8 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
});
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
vertexColors: true,
const material = new THREE.PointsMaterial({
vertexColors: true,
size: 0.7,
sizeAttenuation: true,
transparent: true,
@ -333,8 +333,8 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
});
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
vertexColors: true,
const material = new THREE.PointsMaterial({
vertexColors: true,
size: 0.7,
sizeAttenuation: true,
transparent: true,
@ -388,8 +388,8 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
}
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
vertexColors: true,
const material = new THREE.PointsMaterial({
vertexColors: true,
size: 0.7,
sizeAttenuation: true,
transparent: true,
@ -442,4 +442,4 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
return <div ref={containerRef} className="point-cloud-container" style={{ width: "100%", height: "700px", minHeight: 600 }} />;
};
export default EmbryoGeneration;
export default EmbryoGeneration;

View File

@ -162,12 +162,14 @@ const GeneView: React.FC = () => {
setSelectedStage(e.target.value);
setGene("");
setData([]);
setDistributionData([]);
setError("");
}}
>
<option value="CS7">CS7 - </option>
<option value="CS8">CS8 - </option>
<option value="CS9">CS9 - </option>
<option value="CS11">CS11 - </option>
</select>
</label>
@ -205,18 +207,21 @@ const GeneView: React.FC = () => {
className="three-column-grid visualization-grid"
style={{
display: "grid",
gridTemplateColumns: "minmax(400px, 1fr) minmax(400px, 1fr) minmax(450px, 1fr)",
gridTemplateColumns: "700px 500px minmax(350px, 0.8fr)",
gap: "2rem",
minHeight: "600px",
width: "100%"
}}
width: "100%",
"--visualization-width": "500px"
} as React.CSSProperties}
>
{/* 细胞类型可视化 */}
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
<div className="visualization-container" style={{ width: "700px", minWidth: "700px", maxWidth: "700px" }}>
<h3 style={{ textAlign: "center" }}></h3>
{cellError && <div className="error-message">{cellError}</div>}
{cellData.length > 0 ? (
<PointCloud data={cellData} isCategorical={true} />
<div style={{ width: "700px", height: "100%" }}>
<PointCloud data={cellData} isCategorical={true} />
</div>
) : (
<div className="no-data-message">
{cellLoading ? (
@ -238,10 +243,12 @@ const GeneView: React.FC = () => {
</div>
{/* 基因表达可视化 */}
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
<div className="visualization-container" style={{ width: "500px", minWidth: "500px", maxWidth: "500px" }}>
<h3 style={{ textAlign: "center" }}></h3>
{data.length > 0 ? (
<PointCloud data={data} autoRotate={true} />
<div style={{ width: "500px", height: "100%" }}>
<PointCloud data={data} autoRotate={true} />
</div>
) : (
<div className="no-data-message">
{loading ? (
@ -264,7 +271,7 @@ const GeneView: React.FC = () => {
{/* 基因分布箱线图 */}
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
<h3 style={{textAlign: "center" }}></h3>
{distributionData.data && distributionData.data.length > 0 ? (
<div style={{ height: "480px" }}>
<ResponsiveBoxPlot

View File

@ -38,6 +38,10 @@ body {
-moz-osx-font-smoothing: grayscale;
}
h3 {
font-size: 1.25rem;
}
/* ===== 应用布局 ===== */
.app {
min-height: 100vh;
@ -459,6 +463,15 @@ button:disabled {
overflow: hidden;
}
.point-cloud-visualization-container {
width: 100%;
height: calc(100% - 1.5rem);
min-width: 400px;
position: relative;
background-color: var(--background-secondary);
overflow: hidden;
}
.no-data-message {
text-align: center;
padding: 4rem 2rem;
@ -490,7 +503,90 @@ button:disabled {
overflow-y: auto;
}
/* ===== 新的图例样式 ===== */
.legend-container {
width: 100%;
}
.legend-container .legend-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
text-align: center;
border-bottom: 1px solid var(--border-light);
padding-bottom: 0.5rem;
font-size: 1.1rem;
}
.legend-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
padding: 0.5rem;
border-radius: 0.375rem;
background-color: white;
border: 1px solid var(--border-light);
transition: all 0.2s ease;
}
.legend-item:hover {
background-color: var(--background-accent);
transform: translateX(2px);
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 0.75rem;
border: 2px solid rgba(0, 0, 0, 0.1);
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.legend-label {
font-size: 0.9rem;
color: var(--text-primary);
line-height: 1.3;
word-break: break-word;
font-weight: 500;
}
.legend-gradient-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.legend-gradient {
background: linear-gradient(to top,
hsla(180, 100%, 50%, 1.00),
hsla(72, 100%, 50%, 1.00),
hsla(0, 100%, 50%, 0.99));
width: 30px;
height: 120px;
border-radius: 4px;
border: 1px solid var(--border-light);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.legend-labels {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 500;
}
/* ===== 原有图例样式(保持兼容性) ===== */
.point-cloud-legend .legend-gradient {
background: linear-gradient(to top,
hsla(180, 100%, 50%, 1.00),
hsla(72, 100%, 50%, 1.00),
@ -501,7 +597,7 @@ button:disabled {
margin-bottom: 0.5rem;
}
.legend-title {
.point-cloud-legend .legend-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
@ -510,14 +606,14 @@ button:disabled {
padding-bottom: 0.5rem;
}
.legend-item {
.point-cloud-legend .legend-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding: 0.25rem 0;
}
.legend-color {
.point-cloud-legend .legend-color {
width: 14px;
height: 14px;
border-radius: 50%;
@ -526,7 +622,7 @@ button:disabled {
flex-shrink: 0;
}
.legend-label {
.point-cloud-legend .legend-label {
font-size: 0.8rem;
color: var(--text-primary);
line-height: 1.2;