新增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',
|
||||
'#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;
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user