新增CS11数据文件,优化PointCloud组件样式和图例展示,调整相机设置以改善3D可视化效果,提升用户体验。
This commit is contained in:
parent
2291f59144
commit
020a3e5cd6
BIN
embryo-backend/Data/CS11.h5ad
Normal file
BIN
embryo-backend/Data/CS11.h5ad
Normal file
Binary file not shown.
@ -36,12 +36,12 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
|
|||||||
'#F8C471', '#82E0AA', '#F1948A', '#85CDCE', '#D7BDE2',
|
'#F8C471', '#82E0AA', '#F1948A', '#85CDCE', '#D7BDE2',
|
||||||
'#A9DFBF', '#F9E79F', '#D5A6BD', '#AED6F1', '#E8DAEF'
|
'#A9DFBF', '#F9E79F', '#D5A6BD', '#AED6F1', '#E8DAEF'
|
||||||
];
|
];
|
||||||
|
|
||||||
cellTypes.forEach((type, index) => {
|
cellTypes.forEach((type, index) => {
|
||||||
const colorHex = colors[index % colors.length];
|
const colorHex = colors[index % colors.length];
|
||||||
colorMap.set(type, new THREE.Color(colorHex));
|
colorMap.set(type, new THREE.Color(colorHex));
|
||||||
});
|
});
|
||||||
|
|
||||||
return colorMap;
|
return colorMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,17 +56,17 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
|
|||||||
cancelAnimationFrame(animationIdRef.current);
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
animationIdRef.current = null;
|
animationIdRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rendererRef.current) {
|
if (rendererRef.current) {
|
||||||
rendererRef.current.dispose();
|
rendererRef.current.dispose();
|
||||||
rendererRef.current = null;
|
rendererRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (geometryRef.current) {
|
if (geometryRef.current) {
|
||||||
geometryRef.current.dispose();
|
geometryRef.current.dispose();
|
||||||
geometryRef.current = null;
|
geometryRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (materialRef.current) {
|
if (materialRef.current) {
|
||||||
materialRef.current.dispose();
|
materialRef.current.dispose();
|
||||||
materialRef.current = null;
|
materialRef.current = null;
|
||||||
@ -83,12 +83,13 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
|
|||||||
// 初始化场景、相机、渲染器
|
// 初始化场景、相机、渲染器
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
sceneRef.current = scene; // 存储scene引用
|
sceneRef.current = scene; // 存储scene引用
|
||||||
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
const camera = new THREE.PerspectiveCamera(600, width / height, 0.1, 1000);
|
||||||
camera.position.set(-15, 15, -12); // 从左下后方观察,使Z轴指向右上方
|
camera.position.set(0, 0, -500); // 从左下后方观察,使Z轴指向右上方
|
||||||
camera.lookAt(0, 0, 0); // 确保相机朝向原点
|
camera.lookAt(0, -100, 0); // 确保相机朝向原点
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
renderer.setSize(width, height);
|
renderer.setSize(width, height);
|
||||||
|
renderer.setClearColor(0xFFFFFF, 1); // 设置背景为透明
|
||||||
rendererRef.current = renderer;
|
rendererRef.current = renderer;
|
||||||
containerRef.current.appendChild(renderer.domElement);
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
@ -125,7 +126,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
|
|||||||
|
|
||||||
data.forEach((point) => {
|
data.forEach((point) => {
|
||||||
positions.push(point.x, point.y, point.z);
|
positions.push(point.x, point.y, point.z);
|
||||||
|
|
||||||
if (isCategorical && colorMap) {
|
if (isCategorical && colorMap) {
|
||||||
// 分类数据:按细胞类型着色
|
// 分类数据:按细胞类型着色
|
||||||
const cellColor = colorMap.get(point.value as string) || new THREE.Color('#CCCCCC');
|
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({
|
const material = new THREE.PointsMaterial({
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
size: 0.5,
|
size: 5,
|
||||||
sizeAttenuation: true,
|
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;
|
materialRef.current = material;
|
||||||
|
|
||||||
const points = new THREE.Points(geometry, material);
|
const points = new THREE.Points(geometry, material);
|
||||||
scene.add(points);
|
scene.add(points);
|
||||||
|
|
||||||
@ -169,13 +191,13 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
|
|||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
const i = intersects[0].index ?? 0;
|
const i = intersects[0].index ?? 0;
|
||||||
const p = data[i];
|
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) {
|
// if (isCategorical) {
|
||||||
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nCell Type: ${p.value}`);
|
// alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nCell Type: ${p.value}`);
|
||||||
} else {
|
// } else {
|
||||||
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nExpression: ${(p.value as number).toFixed(3)}`);
|
// 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 = () => {
|
const animate = () => {
|
||||||
animationIdRef.current = requestAnimationFrame(animate);
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
// 自动旋转逻辑 - 使用 ref 来避免重新创建场景
|
// 自动旋转逻辑 - 使用 ref 来避免重新创建场景
|
||||||
if (isSpinningRef.current && autoRotate) {
|
if (isSpinningRef.current && autoRotate) {
|
||||||
scene.rotation.z += 0.002; // 围绕Z轴更慢速旋转
|
scene.rotation.y += 0.002; // 围绕Z轴更慢速旋转
|
||||||
}
|
}
|
||||||
|
|
||||||
controls.update();
|
controls.update();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
};
|
};
|
||||||
animate();
|
animate();
|
||||||
|
|
||||||
// 图例
|
// 只在非分类数据时在3D图形内显示图例
|
||||||
const legend = document.createElement("div");
|
if (!isCategorical) {
|
||||||
legend.className = "point-cloud-legend";
|
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 = `
|
legend.innerHTML = `
|
||||||
<div class="legend-gradient"></div>
|
<div class="legend-gradient"></div>
|
||||||
<div>↑ High</div>
|
<div>↑ High</div>
|
||||||
<div>↓ Low</div>`;
|
<div>↓ Low</div>`;
|
||||||
|
|
||||||
|
containerRef.current.appendChild(legend);
|
||||||
}
|
}
|
||||||
|
|
||||||
containerRef.current.appendChild(legend);
|
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
return () => {
|
return () => {
|
||||||
@ -232,29 +238,29 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, au
|
|||||||
cancelAnimationFrame(animationIdRef.current);
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
animationIdRef.current = null;
|
animationIdRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除事件监听器
|
// 移除事件监听器
|
||||||
if (rendererRef.current && rendererRef.current.domElement) {
|
if (rendererRef.current && rendererRef.current.domElement) {
|
||||||
rendererRef.current.domElement.removeEventListener("click", onClick);
|
rendererRef.current.domElement.removeEventListener("click", onClick);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理控制器
|
// 清理控制器
|
||||||
if (controlsRef.current) {
|
if (controlsRef.current) {
|
||||||
controlsRef.current.dispose();
|
controlsRef.current.dispose();
|
||||||
controlsRef.current = null;
|
controlsRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 释放Three.js资源
|
// 释放Three.js资源
|
||||||
if (rendererRef.current) {
|
if (rendererRef.current) {
|
||||||
rendererRef.current.dispose();
|
rendererRef.current.dispose();
|
||||||
rendererRef.current = null;
|
rendererRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (geometryRef.current) {
|
if (geometryRef.current) {
|
||||||
geometryRef.current.dispose();
|
geometryRef.current.dispose();
|
||||||
geometryRef.current = null;
|
geometryRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (materialRef.current) {
|
if (materialRef.current) {
|
||||||
materialRef.current.dispose();
|
materialRef.current.dispose();
|
||||||
materialRef.current = null;
|
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%' }}>
|
const renderLegend = () => {
|
||||||
<div ref={containerRef} className="point-cloud-container" />
|
if (isCategorical) {
|
||||||
{autoRotate === true && (
|
// 分类数据图例
|
||||||
<div className="spin-control" style={{
|
const cellTypes = Array.from(new Set(data.map(p => p.value as string))).sort();
|
||||||
position: 'absolute',
|
const colorMap = generateCellTypeColors(cellTypes);
|
||||||
top: '15px',
|
|
||||||
left: '15px',
|
return (
|
||||||
background: 'rgba(0, 0, 0, 0.8)',
|
<div>
|
||||||
color: 'white',
|
<div style={{ fontWeight: '600', marginBottom: '10px', fontSize: '14px' }}>细胞类型</div>
|
||||||
padding: '10px 15px',
|
<div>
|
||||||
borderRadius: '8px',
|
{cellTypes.map(type => {
|
||||||
border: '2px solid #fff',
|
const cellColor = colorMap.get(type);
|
||||||
fontSize: '14px',
|
if (cellColor) {
|
||||||
fontWeight: '500',
|
const hexColor = `#${cellColor.getHexString()}`;
|
||||||
zIndex: 1000,
|
return (
|
||||||
display: 'flex',
|
<div key={type} style={{ display: 'flex', alignItems: 'center', marginBottom: '5px' }}>
|
||||||
alignItems: 'center',
|
<div
|
||||||
gap: '8px',
|
style={{
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
|
width: '12px',
|
||||||
pointerEvents: 'auto'
|
height: '12px',
|
||||||
}}>
|
borderRadius: '50%',
|
||||||
<input
|
backgroundColor: hexColor,
|
||||||
type="checkbox"
|
marginRight: '8px'
|
||||||
id={checkboxId}
|
}}
|
||||||
checked={isSpinning}
|
/>
|
||||||
onChange={(e) => setIsSpinning(e.target.checked)}
|
<span style={{ fontSize: '12px' }}>{type}</span>
|
||||||
style={{
|
</div>
|
||||||
cursor: 'pointer',
|
);
|
||||||
width: '16px',
|
}
|
||||||
height: '16px',
|
return null;
|
||||||
accentColor: '#4ECDC4'
|
})}
|
||||||
}}
|
</div>
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={checkboxId}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
userSelect: 'none',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Auto Spin
|
|
||||||
</label>
|
|
||||||
</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;
|
export default PointCloud;
|
||||||
|
|||||||
@ -97,8 +97,8 @@ const EmbryoGeneration: React.FC = () => {
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
flexWrap: "wrap"
|
flexWrap: "wrap"
|
||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={startAnimation}
|
onClick={startAnimation}
|
||||||
disabled={isAnimating || loading}
|
disabled={isAnimating || loading}
|
||||||
style={{
|
style={{
|
||||||
padding: "0.75rem 1.5rem",
|
padding: "0.75rem 1.5rem",
|
||||||
@ -112,7 +112,7 @@ const EmbryoGeneration: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{isAnimating ? "动画中..." : "开始动画"}
|
{isAnimating ? "动画中..." : "开始动画"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={resetAnimation}
|
onClick={resetAnimation}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{
|
||||||
@ -291,8 +291,8 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
|
|||||||
});
|
});
|
||||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||||
const material = new THREE.PointsMaterial({
|
const material = new THREE.PointsMaterial({
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
size: 0.7,
|
size: 0.7,
|
||||||
sizeAttenuation: true,
|
sizeAttenuation: true,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@ -333,8 +333,8 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
|
|||||||
});
|
});
|
||||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||||
const material = new THREE.PointsMaterial({
|
const material = new THREE.PointsMaterial({
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
size: 0.7,
|
size: 0.7,
|
||||||
sizeAttenuation: true,
|
sizeAttenuation: true,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@ -388,8 +388,8 @@ const UnifiedEmbryoAnimation: React.FC<UnifiedEmbryoAnimationProps> = ({ cs7Data
|
|||||||
}
|
}
|
||||||
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3));
|
||||||
const material = new THREE.PointsMaterial({
|
const material = new THREE.PointsMaterial({
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
size: 0.7,
|
size: 0.7,
|
||||||
sizeAttenuation: true,
|
sizeAttenuation: true,
|
||||||
transparent: 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 }} />;
|
return <div ref={containerRef} className="point-cloud-container" style={{ width: "100%", height: "700px", minHeight: 600 }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EmbryoGeneration;
|
export default EmbryoGeneration;
|
||||||
@ -162,12 +162,14 @@ const GeneView: React.FC = () => {
|
|||||||
setSelectedStage(e.target.value);
|
setSelectedStage(e.target.value);
|
||||||
setGene("");
|
setGene("");
|
||||||
setData([]);
|
setData([]);
|
||||||
|
setDistributionData([]);
|
||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="CS7">CS7 - 早期发育阶段</option>
|
<option value="CS7">CS7 - 早期发育阶段</option>
|
||||||
<option value="CS8">CS8 - 中期发育阶段</option>
|
<option value="CS8">CS8 - 中期发育阶段</option>
|
||||||
<option value="CS9">CS9 - 后期发育阶段</option>
|
<option value="CS9">CS9 - 后期发育阶段</option>
|
||||||
|
<option value="CS11">CS11 - 后期发育阶段</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -205,18 +207,21 @@ const GeneView: React.FC = () => {
|
|||||||
className="three-column-grid visualization-grid"
|
className="three-column-grid visualization-grid"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "minmax(400px, 1fr) minmax(400px, 1fr) minmax(450px, 1fr)",
|
gridTemplateColumns: "700px 500px minmax(350px, 0.8fr)",
|
||||||
gap: "2rem",
|
gap: "2rem",
|
||||||
minHeight: "600px",
|
minHeight: "600px",
|
||||||
width: "100%"
|
width: "100%",
|
||||||
}}
|
"--visualization-width": "500px"
|
||||||
|
} as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{/* 细胞类型可视化 */}
|
{/* 细胞类型可视化 */}
|
||||||
<div className="visualization-container">
|
<div className="visualization-container" style={{ width: "700px", minWidth: "700px", maxWidth: "700px" }}>
|
||||||
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>细胞类型分布</h3>
|
<h3 style={{ textAlign: "center" }}>细胞类型分布</h3>
|
||||||
{cellError && <div className="error-message">{cellError}</div>}
|
{cellError && <div className="error-message">{cellError}</div>}
|
||||||
{cellData.length > 0 ? (
|
{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">
|
<div className="no-data-message">
|
||||||
{cellLoading ? (
|
{cellLoading ? (
|
||||||
@ -238,10 +243,12 @@ const GeneView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 基因表达可视化 */}
|
{/* 基因表达可视化 */}
|
||||||
<div className="visualization-container">
|
<div className="visualization-container" style={{ width: "500px", minWidth: "500px", maxWidth: "500px" }}>
|
||||||
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>基因表达模式</h3>
|
<h3 style={{ textAlign: "center" }}>基因表达模式</h3>
|
||||||
{data.length > 0 ? (
|
{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">
|
<div className="no-data-message">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -264,7 +271,7 @@ const GeneView: React.FC = () => {
|
|||||||
|
|
||||||
{/* 基因分布箱线图 */}
|
{/* 基因分布箱线图 */}
|
||||||
<div className="visualization-container">
|
<div className="visualization-container">
|
||||||
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>基因表达分布</h3>
|
<h3 style={{textAlign: "center" }}>基因表达分布</h3>
|
||||||
{distributionData.data && distributionData.data.length > 0 ? (
|
{distributionData.data && distributionData.data.length > 0 ? (
|
||||||
<div style={{ height: "480px" }}>
|
<div style={{ height: "480px" }}>
|
||||||
<ResponsiveBoxPlot
|
<ResponsiveBoxPlot
|
||||||
|
|||||||
@ -38,6 +38,10 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 应用布局 ===== */
|
/* ===== 应用布局 ===== */
|
||||||
.app {
|
.app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@ -459,6 +463,15 @@ button:disabled {
|
|||||||
overflow: hidden;
|
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 {
|
.no-data-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
@ -490,7 +503,90 @@ button:disabled {
|
|||||||
overflow-y: auto;
|
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 {
|
.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,
|
background: linear-gradient(to top,
|
||||||
hsla(180, 100%, 50%, 1.00),
|
hsla(180, 100%, 50%, 1.00),
|
||||||
hsla(72, 100%, 50%, 1.00),
|
hsla(72, 100%, 50%, 1.00),
|
||||||
@ -501,7 +597,7 @@ button:disabled {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-title {
|
.point-cloud-legend .legend-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@ -510,14 +606,14 @@ button:disabled {
|
|||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-item {
|
.point-cloud-legend .legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-color {
|
.point-cloud-legend .legend-color {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@ -526,7 +622,7 @@ button:disabled {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-label {
|
.point-cloud-legend .legend-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user