新增细胞类型API,更新前端以支持细胞类型和基因表达的双重可视化,优化样式和交互体验。
This commit is contained in:
parent
185a504076
commit
37692d5775
@ -57,5 +57,42 @@ def get_gene_expression():
|
|||||||
]
|
]
|
||||||
return jsonify({"gene": gene, "expression": result})
|
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__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
|
|||||||
@ -6,17 +6,36 @@ interface Point {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z: number;
|
z: number;
|
||||||
value: number; // 表达强度
|
value: number | string; // 表达强度或细胞类型
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PointCloudProps {
|
interface PointCloudProps {
|
||||||
data: Point[];
|
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 containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const rendererRef = useRef<THREE.WebGLRenderer | undefined>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || data.length === 0) return;
|
if (!containerRef.current || data.length === 0) return;
|
||||||
|
|
||||||
@ -51,14 +70,36 @@ const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
|
|||||||
const colors: number[] = [];
|
const colors: number[] = [];
|
||||||
const sizes: 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 color = new THREE.Color();
|
||||||
const maxValue = Math.max(...data.map((p) => p.value));
|
|
||||||
|
|
||||||
data.forEach((point) => {
|
data.forEach((point) => {
|
||||||
positions.push(point.x, point.y, point.z);
|
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);
|
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));
|
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 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}`);
|
||||||
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();
|
animate();
|
||||||
|
|
||||||
// 图例(颜色条)
|
// 图例
|
||||||
const legend = document.createElement("div");
|
const legend = document.createElement("div");
|
||||||
legend.className = "point-cloud-legend";
|
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);
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
@ -120,7 +187,7 @@ const PointCloud: React.FC<PointCloudProps> = ({ data }) => {
|
|||||||
material.dispose();
|
material.dispose();
|
||||||
renderer.domElement.removeEventListener("click", onClick);
|
renderer.domElement.removeEventListener("click", onClick);
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data, isCategorical]);
|
||||||
|
|
||||||
return <div ref={containerRef} className="point-cloud-container" />;
|
return <div ref={containerRef} className="point-cloud-container" />;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,8 +6,11 @@ import "../style.css";
|
|||||||
const GeneView: React.FC = () => {
|
const GeneView: React.FC = () => {
|
||||||
const [gene, setGene] = useState("");
|
const [gene, setGene] = useState("");
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
const [cellData, setCellData] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [cellLoading, setCellLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [cellError, setCellError] = useState("");
|
||||||
const [availableGenes, setAvailableGenes] = useState<string[]>([]);
|
const [availableGenes, setAvailableGenes] = useState<string[]>([]);
|
||||||
const [selectedStage, setSelectedStage] = useState("CS7");
|
const [selectedStage, setSelectedStage] = useState("CS7");
|
||||||
|
|
||||||
@ -22,6 +25,33 @@ const GeneView: React.FC = () => {
|
|||||||
.catch(() => setAvailableGenes([]));
|
.catch(() => setAvailableGenes([]));
|
||||||
}, [selectedStage]);
|
}, [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 () => {
|
const handleSearch = async () => {
|
||||||
if (!gene) return;
|
if (!gene) return;
|
||||||
|
|
||||||
@ -113,14 +143,43 @@ const GeneView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<h2>3D 表达可视化</h2>
|
<h2>3D 可视化</h2>
|
||||||
<p style={{ marginBottom: "1.5rem", color: "var(--text-secondary)" }}>
|
<p style={{ marginBottom: "1.5rem", color: "var(--text-secondary)" }}>
|
||||||
下方的3D可视化展示了所选基因在{selectedStage}发育阶段的空间表达模式。
|
下方展示了{selectedStage}发育阶段的细胞类型分布(左)和所选基因的表达模式(右)。
|
||||||
点击任意数据点可查看详细的表达信息。
|
点击任意数据点可查看详细信息。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* 可视化区域 */}
|
{/* 双可视化区域 */}
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem", minHeight: "500px" }}>
|
||||||
|
{/* 细胞类型可视化 */}
|
||||||
<div className="visualization-container">
|
<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 ? (
|
{data.length > 0 ? (
|
||||||
<PointCloud data={data} />
|
<PointCloud data={data} />
|
||||||
) : (
|
) : (
|
||||||
@ -133,20 +192,24 @@ const GeneView: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>📊</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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="section">
|
<div className="section">
|
||||||
<h2>使用说明</h2>
|
<h2>使用说明</h2>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "2rem", marginTop: "1.5rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", gap: "2rem", marginTop: "1.5rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<h3>🎯 选择目标</h3>
|
<h3>🎯 选择目标</h3>
|
||||||
<p>首先选择感兴趣的发育阶段,然后从下拉菜单中选择要查看的基因。</p>
|
<p>首先选择感兴趣的发育阶段,系统将自动显示该阶段的细胞类型分布,然后选择基因查看表达模式。</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>🖱️ 交互操作</h3>
|
<h3>🖱️ 交互操作</h3>
|
||||||
@ -154,7 +217,7 @@ const GeneView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>🎨 颜色编码</h3>
|
<h3>🎨 颜色编码</h3>
|
||||||
<p>颜色代表基因表达强度:红色表示高表达,蓝色表示低表达。</p>
|
<p>左侧:不同颜色代表不同细胞类型。右侧:颜色代表基因表达强度,红色表示高表达,蓝色表示低表达。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -470,6 +470,9 @@ button:disabled {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-gradient {
|
.legend-gradient {
|
||||||
@ -483,6 +486,38 @@ button:disabled {
|
|||||||
margin-bottom: 0.5rem;
|
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 {
|
.clustering-features, .resource-grid, .software-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user