新增细胞类型API,更新前端以支持细胞类型和基因表达的双重可视化,优化样式和交互体验。

This commit is contained in:
wjsjwr 2025-07-26 12:47:49 +08:00
parent 185a504076
commit 37692d5775
4 changed files with 239 additions and 37 deletions

View File

@ -57,5 +57,42 @@ def get_gene_expression():
]
return jsonify({"gene": gene, "expression": result})
@app.route("/api/cell")
def get_cell_types():
stage = request.args.get("stage")
adata = load_adata(stage)
if not adata:
return jsonify({"error": f"Stage '{stage}' not found"}), 404
# 查找细胞类型列,按常见的列名优先级查找
cell_type_columns = ['cell_type', 'celltype', 'cluster', 'annotation', 'cell_types', 'clusters']
cell_type_col = None
for col in cell_type_columns:
if col in adata.obs.columns:
cell_type_col = col
break
if cell_type_col is None:
return jsonify({"error": "No cell type information found"}), 404
# 读取细胞类型
cell_types = adata.obs[cell_type_col].values
# 读取三维坐标
if "spatial" in adata.obsm:
coords = adata.obsm["spatial"]
elif all(k in adata.obs for k in ("x", "y", "z")):
coords = adata.obs[["x", "y", "z"]].values
else:
return jsonify({"error": "No spatial coordinates found"}), 500
result = [
{"x": float(x), "y": float(y), "z": float(z), "value": str(ct)}
for (x, y, z), ct in zip(coords, cell_types)
]
return jsonify({"stage": stage, "cells": result})
if __name__ == "__main__":
app.run(debug=True)

View File

@ -6,17 +6,36 @@ interface Point {
x: number;
y: number;
z: number;
value: number; // 表达强度
value: number | string; // 表达强度或细胞类型
}
interface PointCloudProps {
data: Point[];
isCategorical?: boolean; // 是否为分类数据(细胞类型)
}
const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
const PointCloud: React.FC<PointCloudProps> = ({ data, isCategorical = false }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | undefined>(undefined);
// 为细胞类型生成颜色映射
const generateCellTypeColors = (cellTypes: string[]) => {
const colorMap = new Map<string, THREE.Color>();
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#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;
};
useEffect(() => {
if (!containerRef.current || data.length === 0) return;
@ -51,14 +70,36 @@ const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
const colors: number[] = [];
const sizes: number[] = [];
let colorMap: Map<string, THREE.Color> | null = null;
let cellTypes: string[] = [];
let maxValue = 0;
if (isCategorical) {
// 分类数据处理
cellTypes = Array.from(new Set(data.map(p => p.value as string))).sort();
colorMap = generateCellTypeColors(cellTypes);
} else {
// 数值数据处理
maxValue = Math.max(...data.map((p) => p.value as number));
}
const color = new THREE.Color();
const maxValue = Math.max(...data.map((p) => p.value));
data.forEach((point) => {
positions.push(point.x, point.y, point.z);
color.setHSL(0.6 * (1 - point.value / maxValue), 1.0, 0.5);
if (isCategorical && colorMap) {
// 分类数据:按细胞类型着色
const cellColor = colorMap.get(point.value as string) || new THREE.Color('#CCCCCC');
colors.push(cellColor.r, cellColor.g, cellColor.b);
sizes.push(0.8); // 统一大小
} else {
// 数值数据:按表达强度着色
const value = point.value as number;
color.setHSL(0.6 * (1 - value / maxValue), 1.0, 0.5);
colors.push(color.r, color.g, color.b);
sizes.push(0.3 + 1.5 * point.value / maxValue); // 点大小映射
sizes.push(0.3 + 1.5 * value / maxValue);
}
});
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
@ -90,7 +131,12 @@ const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
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}`);
alert(`Cell @ (${p.x.toFixed(1)}, ${p.y.toFixed(1)}, ${p.z.toFixed(1)})\nExpression: ${p.value.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)}`);
}
}
};
@ -104,13 +150,34 @@ const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
};
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 {
// 数值数据图例
legend.innerHTML = `
<div class="legend-gradient"></div>
<div> High</div>
<div> Low</div>`;
}
containerRef.current.appendChild(legend);
// 清理
@ -120,7 +187,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
material.dispose();
renderer.domElement.removeEventListener("click", onClick);
};
}, [data]);
}, [data, isCategorical]);
return <div ref={containerRef} className="point-cloud-container" />;
};

View File

@ -6,8 +6,11 @@ import "../style.css";
const GeneView: React.FC = () => {
const [gene, setGene] = useState("");
const [data, setData] = useState<any[]>([]);
const [cellData, setCellData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [cellLoading, setCellLoading] = useState(false);
const [error, setError] = useState("");
const [cellError, setCellError] = useState("");
const [availableGenes, setAvailableGenes] = useState<string[]>([]);
const [selectedStage, setSelectedStage] = useState("CS7");
@ -22,6 +25,33 @@ const GeneView: React.FC = () => {
.catch(() => setAvailableGenes([]));
}, [selectedStage]);
// 获取细胞类型数据
useEffect(() => {
if (!selectedStage) return;
setCellLoading(true);
setCellError("");
setCellData([]);
axios
.get("http://localhost:5000/api/cell", {
params: { stage: selectedStage },
})
.then((res) => {
setCellData(res.data.cells);
})
.catch((err) => {
if (err.response && err.response.data?.error) {
setCellError(err.response.data.error);
} else {
setCellError("Failed to fetch cell type data.");
}
})
.finally(() => {
setCellLoading(false);
});
}, [selectedStage]);
const handleSearch = async () => {
if (!gene) return;
@ -113,14 +143,43 @@ const GeneView: React.FC = () => {
</div>
<div className="section">
<h2>3D </h2>
<h2>3D </h2>
<p style={{ marginBottom: "1.5rem", color: "var(--text-secondary)" }}>
3D可视化展示了所选基因在{selectedStage}
{selectedStage}
</p>
{/* 可视化区域 */}
{/* 双可视化区域 */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem", minHeight: "500px" }}>
{/* 细胞类型可视化 */}
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
{cellError && <div className="error-message">{cellError}</div>}
{cellData.length > 0 ? (
<PointCloud data={cellData} isCategorical={true} />
) : (
<div className="no-data-message">
{cellLoading ? (
<div>
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>🔄</div>
<p>...</p>
</div>
) : (
<div>
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>🧬</div>
<p></p>
<p style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }}>
{selectedStage}
</p>
</div>
)}
</div>
)}
</div>
{/* 基因表达可视化 */}
<div className="visualization-container">
<h3 style={{ marginBottom: "1rem", textAlign: "center" }}></h3>
{data.length > 0 ? (
<PointCloud data={data} />
) : (
@ -133,20 +192,24 @@ const GeneView: React.FC = () => {
) : (
<div>
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>📊</div>
<p></p>
<p></p>
<p style={{ fontSize: "0.9rem", color: "var(--text-secondary)" }}>
{selectedStage}
</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="section">
<h2>使</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "2rem", marginTop: "1.5rem" }}>
<div>
<h3>🎯 </h3>
<p></p>
<p></p>
</div>
<div>
<h3>🖱 </h3>
@ -154,7 +217,7 @@ const GeneView: React.FC = () => {
</div>
<div>
<h3>🎨 </h3>
<p></p>
<p></p>
</div>
</div>
</div>

View File

@ -470,6 +470,9 @@ button:disabled {
box-shadow: var(--shadow-md);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
max-width: 200px;
max-height: 400px;
overflow-y: auto;
}
.legend-gradient {
@ -483,6 +486,38 @@ button:disabled {
margin-bottom: 0.5rem;
}
.legend-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.75rem;
text-align: center;
border-bottom: 1px solid var(--border-light);
padding-bottom: 0.5rem;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
padding: 0.25rem 0;
}
.legend-color {
width: 14px;
height: 14px;
border-radius: 50%;
margin-right: 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
.legend-label {
font-size: 0.8rem;
color: var(--text-primary);
line-height: 1.2;
word-break: break-word;
}
/* ===== 其他页面样式 ===== */
.clustering-features, .resource-grid, .software-grid {
display: grid;