新增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

@ -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);
@ -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}`);
// 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)}`);
}
// 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)}`);
// }
}
};
@ -187,7 +209,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
// 自动旋转逻辑 - 使用 ref 来避免重新创建场景
if (isSpinningRef.current && autoRotate) {
scene.rotation.z += 0.002; // 围绕Z轴更慢速旋转
scene.rotation.y += 0.002; // 围绕Z轴更慢速旋转
}
controls.update();
@ -195,35 +217,19 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
};
animate();
// 图例
// 只在非分类数据时在3D图形内显示图例
if (!isCategorical) {
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 {
// 数值数据图例
legend.innerHTML = `
<div class="legend-gradient"></div>
<div> High</div>
<div> Low</div>`;
}
containerRef.current.appendChild(legend);
}
// 清理
return () => {
@ -294,6 +300,99 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
};
}, []);
// 生成图例内容
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>
);
} 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" />
@ -344,6 +443,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
)}
</div>
);
}
};
export default PointCloud;

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 ? (
<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 ? (
<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;