新增查询控制器样式,优化基因表达查询界面,支持自动旋转功能,提升用户交互体验。
This commit is contained in:
parent
1514130adc
commit
93e0eae353
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
|
||||||
@ -12,11 +12,20 @@ interface Point {
|
|||||||
interface PointCloudProps {
|
interface PointCloudProps {
|
||||||
data: Point[];
|
data: Point[];
|
||||||
isCategorical?: boolean; // 是否为分类数据(细胞类型)
|
isCategorical?: boolean; // 是否为分类数据(细胞类型)
|
||||||
|
autoRotate?: boolean; // 是否自动旋转
|
||||||
}
|
}
|
||||||
|
|
||||||
const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false }) => {
|
const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false, autoRotate = false }) => {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const rendererRef = useRef<THREE.WebGLRenderer | undefined>(undefined);
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||||
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
|
const geometryRef = useRef<THREE.BufferGeometry | null>(null);
|
||||||
|
const materialRef = useRef<THREE.PointsMaterial | null>(null);
|
||||||
|
const animationIdRef = useRef<number | null>(null);
|
||||||
|
const controlsRef = useRef<any>(null);
|
||||||
|
const [isSpinning, setIsSpinning] = useState(autoRotate);
|
||||||
|
const isSpinningRef = useRef(autoRotate);
|
||||||
|
const checkboxId = useRef(`spin-checkbox-${Math.random().toString(36).substring(2, 11)}`).current;
|
||||||
|
|
||||||
// 为细胞类型生成颜色映射
|
// 为细胞类型生成颜色映射
|
||||||
const generateCellTypeColors = (cellTypes: string[]) => {
|
const generateCellTypeColors = (cellTypes: string[]) => {
|
||||||
@ -39,8 +48,32 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false })
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || data.length === 0) return;
|
if (!containerRef.current || data.length === 0) return;
|
||||||
|
|
||||||
|
// 确保ref与当前状态同步
|
||||||
|
isSpinningRef.current = isSpinning;
|
||||||
|
|
||||||
|
// 清理之前的资源
|
||||||
|
if (animationIdRef.current !== null) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// 清除旧渲染内容
|
// 清除旧渲染内容
|
||||||
if (containerRef.current.firstChild) {
|
while (containerRef.current.firstChild) {
|
||||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,16 +82,20 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false })
|
|||||||
|
|
||||||
// 初始化场景、相机、渲染器
|
// 初始化场景、相机、渲染器
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
|
sceneRef.current = scene; // 存储scene引用
|
||||||
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
const camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
|
||||||
camera.position.set(0, 0, 30);
|
camera.position.set(-15, 15, -12); // 从左下后方观察,使Z轴指向右上方
|
||||||
|
camera.lookAt(0, 0, 0); // 确保相机朝向原点
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
renderer.setSize(width, height);
|
renderer.setSize(width, height);
|
||||||
|
rendererRef.current = renderer;
|
||||||
containerRef.current.appendChild(renderer.domElement);
|
containerRef.current.appendChild(renderer.domElement);
|
||||||
|
|
||||||
// 控件
|
// 控件
|
||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
|
controlsRef.current = controls;
|
||||||
|
|
||||||
// 坐标轴辅助
|
// 坐标轴辅助
|
||||||
const axesHelper = new THREE.AxesHelper(10);
|
const axesHelper = new THREE.AxesHelper(10);
|
||||||
@ -66,6 +103,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false })
|
|||||||
|
|
||||||
// 点云数据
|
// 点云数据
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometryRef.current = geometry;
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
const colors: number[] = [];
|
const colors: number[] = [];
|
||||||
const sizes: number[] = [];
|
const sizes: number[] = [];
|
||||||
@ -111,6 +149,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false })
|
|||||||
size: 0.5,
|
size: 0.5,
|
||||||
sizeAttenuation: true,
|
sizeAttenuation: true,
|
||||||
});
|
});
|
||||||
|
materialRef.current = material;
|
||||||
|
|
||||||
const points = new THREE.Points(geometry, material);
|
const points = new THREE.Points(geometry, material);
|
||||||
scene.add(points);
|
scene.add(points);
|
||||||
@ -144,7 +183,13 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false })
|
|||||||
|
|
||||||
// 渲染循环
|
// 渲染循环
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
requestAnimationFrame(animate);
|
animationIdRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
// 自动旋转逻辑 - 使用 ref 来避免重新创建场景
|
||||||
|
if (isSpinningRef.current && autoRotate) {
|
||||||
|
scene.rotation.z += 0.002; // 围绕Z轴更慢速旋转
|
||||||
|
}
|
||||||
|
|
||||||
controls.update();
|
controls.update();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
};
|
};
|
||||||
@ -182,14 +227,123 @@ const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false })
|
|||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
return () => {
|
return () => {
|
||||||
renderer.dispose();
|
// 停止动画循环
|
||||||
geometry.dispose();
|
if (animationIdRef.current !== null) {
|
||||||
material.dispose();
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
renderer.domElement.removeEventListener("click", onClick);
|
animationIdRef.current = null;
|
||||||
};
|
}
|
||||||
}, [data, isCategorical]);
|
|
||||||
|
|
||||||
return <div ref={containerRef} className="point-cloud-container" />;
|
// 移除事件监听器
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [data, isCategorical, autoRotate]);
|
||||||
|
|
||||||
|
// 监听autoRotate prop变化,重置旋转状态
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSpinning(autoRotate);
|
||||||
|
isSpinningRef.current = autoRotate;
|
||||||
|
}, [autoRotate]);
|
||||||
|
|
||||||
|
// 监听isSpinning状态变化,只更新ref,不重新创建场景
|
||||||
|
useEffect(() => {
|
||||||
|
isSpinningRef.current = isSpinning;
|
||||||
|
}, [isSpinning]);
|
||||||
|
|
||||||
|
// 组件卸载时的最终清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (animationIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(animationIdRef.current);
|
||||||
|
}
|
||||||
|
if (controlsRef.current) {
|
||||||
|
controlsRef.current.dispose();
|
||||||
|
}
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.dispose();
|
||||||
|
}
|
||||||
|
if (geometryRef.current) {
|
||||||
|
geometryRef.current.dispose();
|
||||||
|
}
|
||||||
|
if (materialRef.current) {
|
||||||
|
materialRef.current.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@ -146,73 +146,71 @@ const GeneView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
<div className="section">
|
{/* 基因表达查询控制器 */}
|
||||||
<h2>基因表达查询</h2>
|
<div className="query-controls" style={{
|
||||||
|
display: "flex",
|
||||||
{/* 阶段选择 */}
|
alignItems: "center",
|
||||||
<div className="stage-selector">
|
gap: "2rem",
|
||||||
<label>
|
flexWrap: "wrap",
|
||||||
发育阶段:
|
marginBottom: "2rem"
|
||||||
<select
|
}}>
|
||||||
value={selectedStage}
|
<label>
|
||||||
onChange={(e) => {
|
发育阶段:
|
||||||
setSelectedStage(e.target.value);
|
|
||||||
setGene("");
|
|
||||||
setData([]);
|
|
||||||
setError("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="CS7">CS7 - 早期发育阶段</option>
|
|
||||||
<option value="CS8">CS8 - 中期发育阶段</option>
|
|
||||||
<option value="CS9">CS9 - 后期发育阶段</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 基因选择 + 按钮 */}
|
|
||||||
<div className="gene-selector">
|
|
||||||
<label htmlFor="gene-select" className="gene-label">目标基因:</label>
|
|
||||||
<select
|
<select
|
||||||
id="gene-select"
|
value={selectedStage}
|
||||||
aria-label="选择基因"
|
onChange={(e) => {
|
||||||
value={gene}
|
setSelectedStage(e.target.value);
|
||||||
onChange={(e) => setGene(e.target.value)}
|
setGene("");
|
||||||
className="gene-select"
|
setData([]);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">-- 请选择基因 --</option>
|
<option value="CS7">CS7 - 早期发育阶段</option>
|
||||||
{availableGenes.map((g) => (
|
<option value="CS8">CS8 - 中期发育阶段</option>
|
||||||
<option key={g} value={g}>
|
<option value="CS9">CS9 - 后期发育阶段</option>
|
||||||
{g}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<button onClick={handleSearch} disabled={!gene || loading}>
|
</label>
|
||||||
{loading ? "加载中..." : "查询表达"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
<label htmlFor="gene-select">目标基因:</label>
|
||||||
{error && <div className="error-message">{error}</div>}
|
<select
|
||||||
|
id="gene-select"
|
||||||
|
aria-label="选择基因"
|
||||||
|
value={gene}
|
||||||
|
onChange={(e) => setGene(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- 请选择基因 --</option>
|
||||||
|
{availableGenes.map((g) => (
|
||||||
|
<option key={g} value={g}>
|
||||||
|
{g}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button onClick={handleSearch} disabled={!gene || loading}>
|
||||||
|
{loading ? "加载中..." : "查询表达"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section">
|
{/* 错误提示 */}
|
||||||
<h2>3D 可视化</h2>
|
{error && <div className="error-message" style={{ marginBottom: "2rem" }}>{error}</div>}
|
||||||
<p style={{ marginBottom: "1.5rem", color: "var(--text-secondary)" }}>
|
|
||||||
下方展示了{selectedStage}发育阶段的细胞类型分布(左)、所选基因的表达模式(中)和基因在各细胞类型中的表达分布(右)。
|
|
||||||
点击任意数据点可查看详细信息。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 三可视化区域 */}
|
{/* 3D 可视化说明 */}
|
||||||
<div
|
<p className="flat-section-description">
|
||||||
className="three-column-grid"
|
下方展示了{selectedStage}发育阶段的细胞类型分布(左)、所选基因的表达模式(中)和基因在各细胞类型中的表达分布(右)。
|
||||||
style={{
|
点击任意数据点可查看详细信息。
|
||||||
display: "grid",
|
</p>
|
||||||
gridTemplateColumns: "minmax(400px, 1fr) minmax(400px, 1fr) minmax(450px, 1fr)",
|
|
||||||
gap: "2rem",
|
{/* 三可视化区域 */}
|
||||||
minHeight: "600px",
|
<div
|
||||||
width: "100%"
|
className="three-column-grid visualization-grid"
|
||||||
}}
|
style={{
|
||||||
>
|
display: "grid",
|
||||||
|
gridTemplateColumns: "minmax(400px, 1fr) minmax(400px, 1fr) minmax(450px, 1fr)",
|
||||||
|
gap: "2rem",
|
||||||
|
minHeight: "600px",
|
||||||
|
width: "100%"
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* 细胞类型可视化 */}
|
{/* 细胞类型可视化 */}
|
||||||
<div className="visualization-container">
|
<div className="visualization-container">
|
||||||
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>细胞类型分布</h3>
|
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>细胞类型分布</h3>
|
||||||
@ -243,7 +241,7 @@ const GeneView: React.FC = () => {
|
|||||||
<div className="visualization-container">
|
<div className="visualization-container">
|
||||||
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>基因表达模式</h3>
|
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}>基因表达模式</h3>
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
<PointCloud data={data} />
|
<PointCloud data={data} autoRotate={true} />
|
||||||
) : (
|
) : (
|
||||||
<div className="no-data-message">
|
<div className="no-data-message">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -344,7 +342,6 @@ const GeneView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<h2>使用说明</h2>
|
<h2>使用说明</h2>
|
||||||
|
|||||||
@ -744,6 +744,58 @@ button:disabled {
|
|||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 查询控制器样式 ===== */
|
||||||
|
.query-controls {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: var(--background-accent);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--background-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-width: 200px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(44, 82, 130, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls button {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls button:hover:not(:disabled) {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls button:disabled {
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 响应式设计 ===== */
|
/* ===== 响应式设计 ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-container {
|
.nav-container {
|
||||||
@ -789,6 +841,25 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.query-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls select {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-controls button {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 响应式网格布局 ===== */
|
/* ===== 响应式网格布局 ===== */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.three-column-grid {
|
.three-column-grid {
|
||||||
@ -816,3 +887,23 @@ button:disabled {
|
|||||||
min-width: auto;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 扁平化标题样式 ===== */
|
||||||
|
.flat-section-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-section-description {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visualization-grid {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user