Files
PlantAI-dev/apps/web/vue3/kg.html
T
2026-08-03 18:42:47 +08:00

703 lines
35 KiB
HTML
Executable File

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>知识图谱 - V13.1 基因家族分析</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/element-plus@2.7.1/dist/index.full.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@element-plus/icons-vue"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
body { margin: 0; font-family: 'Helvetica Neue', Arial, sans-serif; background: #f5f7fa; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; display: flex; align-items: center; justify-content: space-between; }
.header h1 { margin: 0; font-size: 22px; }
.header .subtitle { font-size: 12px; opacity: 0.8; margin-top: 4px; }
.main-container { display: flex; padding: 15px; gap: 15px; height: calc(100vh - 70px); }
.left-panel { width: 300px; display: flex; flex-direction: column; gap: 10px; }
.control-section { background: white; border-radius: 8px; padding: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.control-section h4 { margin: 0 0 10px 0; color: #409EFF; font-size: 14px; border-bottom: 2px solid #409EFF; padding-bottom: 8px; }
.stat-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
.stat-item:last-child { border-bottom: none; }
.stat-label { color: #666; }
.stat-value { font-weight: bold; color: #409EFF; }
.filter-section { margin-top: 10px; }
.filter-tag { margin: 4px; cursor: pointer; }
.filter-tag.active { background: #409EFF; color: white; }
.main-panel { flex: 1; display: flex; flex-direction: column; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
.toolbar { padding: 10px 15px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 10px; }
#graph-container { flex: 1; position: relative; }
.right-panel { width: 350px; background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); padding: 15px; overflow-y: auto; }
.right-panel h4 { margin: 0 0 10px 0; color: #67C23A; border-bottom: 2px solid #67C23A; padding-bottom: 8px; }
.node-detail { padding: 10px; background: #f5f7fa; border-radius: 6px; margin-top: 10px; }
.node-detail .label { color: #666; font-size: 12px; margin-bottom: 4px; }
.node-detail .value { font-weight: 500; color: #333; }
.relation-list { margin-top: 10px; }
.relation-item { padding: 8px; background: #f0f9eb; border-radius: 4px; margin-bottom: 8px; font-size: 13px; }
.relation-item .source { color: #409EFF; font-weight: 500; }
.relation-item .rel { color: #67C23A; margin: 0 4px; }
.relation-item .target { color: #E6A23C; font-weight: 500; }
.search-box { margin-bottom: 10px; }
.legend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.legend-item { display: flex; align-items: center; gap: 4px; font-size: 12px; }
.legend-color { width: 12px; height: 12px; border-radius: 50%; }
.legend-symbol { width: 12px; height: 12px; border-radius: 2px; }
.legend-line { width: 20px; height: 2px; }
/* PC语义分类样式 */
.pc-category-header {
font-size: 11px;
color: #666;
font-weight: bold;
margin: 8px 0 4px 0;
padding-left: 4px;
border-left: 3px solid #409EFF;
}
</style>
</head>
<body>
<div id="app">
<div class="header">
<div>
<h1>🧬 知识图谱可视化</h1>
<div class="subtitle">V13.1 基因家族分析系统 - Pecan SPL</div>
</div>
<el-button type="primary" @click="goBack">
<el-icon><arrow-left /></el-icon> 返回主界面
</el-button>
</div>
<div class="main-container">
<!-- 左侧面板 -->
<div class="left-panel">
<div class="control-section">
<h4>📊 图谱统计</h4>
<div class="stat-item">
<span class="stat-label">节点总数</span>
<span class="stat-value">{{ stats.nodes }}</span>
</div>
<div class="stat-item">
<span class="stat-label">关系总数</span>
<span class="stat-value">{{ stats.edges }}</span>
</div>
<div class="stat-item">
<span class="stat-label">节点类型</span>
<span class="stat-value">{{ stats.nodeTypes }}</span>
</div>
<div class="stat-item">
<span class="stat-label">关系类型</span>
<span class="stat-value">{{ stats.edgeTypes }}</span>
</div>
</div>
<div class="control-section">
<h4>🔍 节点搜索</h4>
<el-input
v-model="searchQuery"
placeholder="输入基因名或实体..."
clearable
@keyup.enter="searchNode"
>
<template #append>
<el-button @click="searchNode">
<el-icon><search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="control-section">
<h4>🏷️ 关系过滤 (PC语义)</h4>
<div class="filter-section">
<div v-for="(types, category) in relationTypesByCategory" :key="category" style="margin-bottom: 10px;">
<div style="font-size: 12px; color: #666; margin-bottom: 5px; font-weight: bold;">
{{ category === 'activation' ? '激活/诱导' :
category === 'repression' ? '抑制/下调' :
category === 'regulation' ? '调控' :
category === 'expression' ? '表达' :
category === 'association' ? '关联' :
category === 'localization' ? '定位' :
category === 'containment' ? '包含' :
category === 'participation' ? '参与' : '其他' }}
</div>
<el-tag
v-for="type in types"
:key="type"
class="filter-tag"
:class="{ active: selectedRelations.includes(type) }"
@click="toggleRelationFilter(type)"
size="small"
:style="{ borderColor: relationEdgeStyles[category].color, color: selectedRelations.includes(type) ? '#fff' : relationEdgeStyles[category].color }"
>
{{ type }} ({{ relationCounts[type] }})
</el-tag>
</div>
</div>
</div>
<div class="control-section">
<h4>🎨 节点图例 (PC语义)</h4>
<div class="pc-category-header">Gene/Protein</div>
<div class="legend">
<div class="legend-item" v-for="type in ['gene', 'gene_id', 'gene_group', 'gene_family']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Phenotype</div>
<div class="legend">
<div class="legend-item" v-for="type in ['phenotype', 'attribute']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Cell/Organ/Organism</div>
<div class="legend">
<div class="legend-item" v-for="type in ['tissue', 'species']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Biological Process</div>
<div class="legend">
<div class="legend-item" v-for="type in ['process', 'time', 'condition', 'stress']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<div class="pc-category-header">Genomic</div>
<div class="legend">
<div class="legend-item" v-for="type in ['location', 'sra_dataset', 'data_type', 'count']" :key="type">
<div class="legend-symbol" :style="{ background: nodeColors[type] }"></div>
<span>{{ nodeTypeLabels[type] }}</span>
</div>
</div>
<h4 style="margin-top: 15px;">🔗 关系图例 (PC语义)</h4>
<div class="legend">
<div class="legend-item" v-for="(style, cat) in relationEdgeStyles" :key="cat">
<div class="legend-line" :style="{ background: style.color, height: style.width + 'px', borderTop: style.type === 'dashed' ? '2px dashed ' + style.color : 'none' }"></div>
<span>{{ cat === 'activation' ? 'Activation/Induction' :
cat === 'repression' ? 'Repression/Inhibition' :
cat === 'regulation' ? 'Regulation/Control' :
cat === 'expression' ? 'Expression/Detection' :
cat === 'association' ? 'Association/Interaction' :
cat === 'localization' ? 'Localization' :
cat === 'containment' ? 'Containment/Composition' :
cat === 'participation' ? 'Participation/Function' : 'Other' }}</span>
</div>
</div>
</div>
</div>
<!-- 主面板 -->
<div class="main-panel">
<div class="toolbar">
<el-button-group>
<el-button @click="fitGraph">
<el-icon><full-screen /></el-icon> 适应视图
</el-button>
<el-button @click="resetFilters">
<el-icon><refresh /></el-icon> 重置过滤
</el-button>
</el-button-group>
<el-divider direction="vertical"></el-divider>
<span style="color: #666; font-size: 13px;">
💡 点击节点查看详情,拖拽调整布局
</span>
</div>
<div id="graph-container"></div>
</div>
<!-- 右侧面板 -->
<div class="right-panel">
<h4>📋 节点详情</h4>
<div v-if="selectedNode" class="node-detail">
<div style="margin-bottom: 15px;">
<div class="label">节点名称</div>
<div class="value" style="font-size: 18px; color: #409EFF;">{{ selectedNode.name }}</div>
</div>
<div style="margin-bottom: 15px;">
<div class="label">节点类型 (PC)</div>
<div class="value">
<el-tag :type="getNodeTypeTag(selectedNode.category)" :style="{ backgroundColor: nodeColors[selectedNode.category] + '20', borderColor: nodeColors[selectedNode.category], color: nodeColors[selectedNode.category] }">
{{ nodeTypeLabels[selectedNode.category] || selectedNode.category }}
</el-tag>
</div>
</div>
<div>
<div class="label">连接关系数</div>
<div class="value">{{ selectedNode.degree }} 个</div>
</div>
</div>
<div v-else style="color: #999; text-align: center; padding: 40px;">
点击图谱中的节点查看详情
</div>
<template v-if="selectedNodeRelations.length > 0">
<h4 style="margin-top: 20px;">🔗 相关关系 ({{ selectedNodeRelations.length }})</h4>
<div class="relation-list">
<div
v-for="rel in selectedNodeRelations.slice(0, 10)"
:key="rel.id"
class="relation-item"
:style="{ borderLeft: '3px solid ' + relationEdgeStyles[rel.category || relationCategories[rel.name] || 'other'].color }"
>
<span class="source">{{ rel.source }}</span>
<span class="rel" :style="{ color: relationEdgeStyles[rel.category || relationCategories[rel.name] || 'other'].color }">--{{ rel.name }}--></span>
<span class="target">{{ rel.target }}</span>
</div>
<div v-if="selectedNodeRelations.length > 10" style="text-align: center; color: #999; padding: 10px;">
还有 {{ selectedNodeRelations.length - 10 }} 个关系...
</div>
</div>
</template>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, computed } = Vue;
const { ElMessage } = ElementPlus;
createApp({
setup() {
const graphData = ref({ nodes: [], edges: [] });
const chart = ref(null);
const searchQuery = ref('');
const selectedNode = ref(null);
const selectedRelations = ref([]);
// PC (Pathway Commons) 语义分类颜色映射
const nodeColors = {
// Gene/Protein - 蓝色
'gene': '#5DADE2',
'gene_id': '#85C1E9',
'gene_group': '#AED6F1',
'gene_family': '#5DADE2',
// Phenotype - 绿色
'phenotype': '#58D68D',
'attribute': '#82E0AA',
// Cell/Organ/Organism - 粉色
'tissue': '#F5B7B1',
'species': '#F1948A',
// Process - 紫色菱形
'process': '#AF7AC5',
'time': '#C39BD3',
'condition': '#D7BDE2',
'stress': '#E8DAEF',
// Location - 浅绿色
'location': '#82E0AA',
// Data - 橙色(更显眼)
'sra_dataset': '#E67E22',
'data_type': '#F39C12',
'count': '#EAEDED',
'unknown': '#95A5A6'
,
'article': '#2ecc71',
'paper_title': '#9b59b6'
};
// PC 语义分类显示名称
const nodeTypeLabels = {
'gene': 'Gene/Protein',
'gene_id': 'Gene Identifier',
'gene_group': 'Gene Group',
'gene_family': 'Gene Family',
'phenotype': 'Phenotype',
'attribute': 'Attribute',
'tissue': 'Cell/Organ/Tissue',
'species': 'Organism',
'process': 'Biological Process',
'time': 'Time/Stage',
'condition': 'Condition',
'stress': 'Stress',
'location': 'Genomic Location',
'sra_dataset': 'SRA Dataset',
'geo_dataset': 'GEO Dataset',
'data_type': 'Data Type',
'count': 'Count/Statistic',
'article': 'Article (PMID)',
'paper_title': 'Paper Title',
'unknown': 'Other'
};
// PC 关系类型分类
const relationCategories = {
// Activation/Induction - 绿色箭头
'induces': 'activation',
'promote': 'activation',
'overexpression_increases': 'activation',
'enhanced_tolerance_to': 'activation',
// Repression/Inhibition - 红色T型
'down_regulated_during': 'repression',
'down_regulation_improves': 'repression',
// Regulation - 蓝色
'regulate': 'regulation',
'regulates': 'regulation',
'regulating': 'regulation',
// Expression - 橙色
'expressed_in': 'expression',
'expressed_during': 'expression',
'was_expressed_during': 'expression',
'expression_reached_peak_at': 'expression',
// Association - 紫色
'collinear_with': 'association',
'was_highly_orthologous_to': 'association',
'related_to': 'association',
'affect': 'association',
// Localization - 深绿
'located_on': 'localization',
'identified_in': 'localization',
// Containment - 青色
'contain': 'containment',
'had': 'containment',
'has_gene_id': 'containment',
'has_data_type': 'containment',
// Participation - 深红
'involved_in': 'participation',
'participate_in': 'participation',
'default': 'other'
};
const relationEdgeStyles = {
'activation': { color: '#27AE60', width: 2, type: 'solid' },
'repression': { color: '#E74C3C', width: 2, type: 'solid' },
'regulation': { color: '#3498DB', width: 2, type: 'solid' },
'expression': { color: '#E67E22', width: 1.5, type: 'solid' },
'association': { color: '#9B59B6', width: 1.5, type: 'dashed' },
'localization': { color: '#16A085', width: 1.5, type: 'solid' },
'containment': { color: '#1ABC9C', width: 1.5, type: 'solid' },
'participation': { color: '#C0392B', width: 2, type: 'solid' },
'other': { color: '#95A5A6', width: 1, type: 'solid' }
};
const stats = computed(() => ({
nodes: graphData.value.nodes.length,
edges: graphData.value.edges.length,
nodeTypes: Object.keys(nodeColors).length,
edgeTypes: relationTypes.value.length
}));
// 按 PC 语义分类的关系类型
const relationTypesByCategory = computed(() => {
const cats = {};
graphData.value.edges.forEach(e => {
const cat = relationCategories[e.relation] || 'other';
if (!cats[cat]) cats[cat] = [];
cats[cat].push(e.relation);
});
// 去重并排序
for (const cat in cats) {
cats[cat] = [...new Set(cats[cat])].sort();
}
return cats;
});
const relationTypes = computed(() => {
const types = new Set();
graphData.value.edges.forEach(e => types.add(e.relation));
return Array.from(types).sort();
});
const relationCounts = computed(() => {
const counts = {};
graphData.value.edges.forEach(e => {
counts[e.relation] = (counts[e.relation] || 0) + 1;
});
return counts;
});
const selectedNodeRelations = computed(() => {
if (!selectedNode.value) return [];
return graphData.value.edges.filter(e =>
e.source === selectedNode.value.id || e.target === selectedNode.value.id
);
});
const loadData = async () => {
try {
// 优先加载包含多组学数据的知识图谱
let response = await fetch('knowledge_graph_with_omics.json');
if (!response.ok) {
// 回退到标准知识图谱
response = await fetch('knowledge_graph.json');
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 验证数据结构
if (!data.nodes || !Array.isArray(data.nodes)) {
throw new Error('数据格式错误: nodes 未定义或不是数组');
}
if (!data.edges || !Array.isArray(data.edges)) {
console.warn('数据警告: edges 未定义,使用空数组');
data.edges = [];
}
// 计算节点度数
const nodeDegrees = {};
data.edges.forEach(e => {
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
});
// 根据 PC 语义分类设置节点形状
const getNodeSymbol = (type) => {
const symbolMap = {
'gene': 'circle',
'gene_id': 'circle',
'gene_group': 'circle',
'gene_family': 'circle',
'phenotype': 'rect',
'attribute': 'rect',
'tissue': 'roundRect',
'species': 'roundRect',
'process': 'diamond',
'time': 'diamond',
'condition': 'diamond',
'stress': 'diamond',
'location': 'triangle',
'sra_dataset': 'pin',
'data_type': 'pin',
'count': 'pin'
};
return symbolMap[type] || 'circle';
};
graphData.value = {
nodes: data.nodes.map(n => ({
id: n.id,
name: n.id,
category: n.type || 'unknown',
degree: nodeDegrees[n.id] || 0,
symbol: getNodeSymbol(n.type),
symbolSize: (n.type === 'gene' ? 20 : n.type === 'sra_dataset' ? 18 : 12) + Math.min((nodeDegrees[n.id] || 0) * 0.3, 15),
itemStyle: {
color: nodeColors[n.type] || nodeColors.unknown,
borderColor: '#fff',
borderWidth: 1
}
})),
edges: data.edges.map((e, i) => {
const cat = relationCategories[e.relation] || 'other';
const style = relationEdgeStyles[cat];
return {
id: i,
source: e.source,
target: e.target,
name: e.relation,
relation: e.relation,
category: cat,
lineStyle: {
color: style.color,
width: style.width,
type: style.type,
curveness: 0.1
}
};
})
};
initChart();
ElMessage.success('知识图谱加载完成 (PC语义规范)');
} catch (error) {
ElMessage.error('加载数据失败: ' + error.message);
}
};
const initChart = () => {
const container = document.getElementById('graph-container');
chart.value = echarts.init(container);
const option = {
tooltip: {
formatter: params => {
if (params.dataType === 'node') {
const pcType = nodeTypeLabels[params.data.category] || params.data.category;
return `<b>${params.name}</b><br/>PC类型: ${pcType}<br/>关系数: ${params.data.degree}`;
}
const cat = params.data.category || relationCategories[params.name] || 'other';
const catName = cat === 'activation' ? 'Activation' :
cat === 'repression' ? 'Repression' :
cat === 'regulation' ? 'Regulation' :
cat === 'expression' ? 'Expression' :
cat === 'association' ? 'Association' :
cat === 'localization' ? 'Localization' :
cat === 'containment' ? 'Containment' :
cat === 'participation' ? 'Participation' : 'Other';
return `<b>${params.data.source}</b> <span style="color:${relationEdgeStyles[cat].color}">--${params.name}--></span> <b>${params.data.target}</b><br/><small>PC: ${catName}</small>`;
}
},
series: [{
type: 'graph',
layout: 'force',
data: graphData.value.nodes,
links: graphData.value.edges,
roam: true,
draggable: true,
label: {
show: true,
position: 'right',
formatter: '{b}'
},
force: {
repulsion: 300,
gravity: 0.1,
edgeLength: 100,
layoutAnimation: true
},
emphasis: {
focus: 'adjacency',
lineStyle: { width: 4 }
}
}]
};
chart.value.setOption(option);
chart.value.on('click', params => {
if (params.dataType === 'node') {
selectedNode.value = params.data;
} else {
selectedNode.value = null;
}
});
window.addEventListener('resize', () => chart.value.resize());
};
const searchNode = () => {
if (!searchQuery.value || !chart.value) return;
const node = graphData.value.nodes.find(n =>
n.id.toLowerCase().includes(searchQuery.value.toLowerCase())
);
if (node) {
chart.value.dispatchAction({
type: 'highlight',
seriesIndex: 0,
name: node.id
});
selectedNode.value = node;
ElMessage.success(`找到节点: ${node.name}`);
} else {
ElMessage.warning('未找到匹配的节点');
}
};
const toggleRelationFilter = (type) => {
const index = selectedRelations.value.indexOf(type);
if (index > -1) {
selectedRelations.value.splice(index, 1);
} else {
selectedRelations.value.push(type);
}
if (selectedRelations.value.length === 0) {
chart.value.setOption({
series: [{ data: graphData.value.nodes, links: graphData.value.edges }]
});
} else {
const filteredEdges = graphData.value.edges.filter(e =>
selectedRelations.value.includes(e.relation)
);
const connectedNodes = new Set();
filteredEdges.forEach(e => {
connectedNodes.add(e.source);
connectedNodes.add(e.target);
});
const filteredNodes = graphData.value.nodes.filter(n =>
connectedNodes.has(n.id)
);
chart.value.setOption({
series: [{ data: filteredNodes, links: filteredEdges }]
});
}
};
const fitGraph = () => {
if (chart.value) {
chart.value.dispatchAction({ type: 'restore' });
}
};
const resetFilters = () => {
selectedRelations.value = [];
selectedNode.value = null;
searchQuery.value = '';
if (chart.value) {
chart.value.setOption({
series: [{ data: graphData.value.nodes, links: graphData.value.edges }]
});
chart.value.dispatchAction({ type: 'restore' });
}
};
const getNodeTypeTag = (type) => {
const typeMap = { 'gene': '', 'gene_id': 'success', 'tissue': 'warning', 'location': 'info' };
return typeMap[type] || '';
};
const goBack = () => {
window.location.href = '/';
};
onMounted(() => {
loadData().then(() => {
// 检查URL参数,如果有highlight则自动搜索高亮
const urlParams = new URLSearchParams(window.location.search);
const highlightEntity = urlParams.get('highlight');
if (highlightEntity) {
searchQuery.value = highlightEntity;
setTimeout(() => {
searchNode();
}, 500); // 等待图表初始化完成
}
});
});
return {
stats,
searchQuery,
selectedNode,
selectedRelations,
relationTypes,
relationTypesByCategory,
relationCounts,
relationEdgeStyles,
relationCategories,
nodeTypeLabels,
selectedNodeRelations,
nodeColors,
searchNode,
toggleRelationFilter,
fitGraph,
resetFilters,
getNodeTypeTag,
goBack
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>