新增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', '#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;

View File

@ -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;

View File

@ -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

View File

@ -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;