713 lines
36 KiB
HTML
Executable File
713 lines
36 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>Knowledge Graph</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>
|
|
<script src="/static/i18n.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>{{ t('kg-visualization') }}</h1>
|
|
<div class="subtitle">{{ t('kg-subtitle') }}</div>
|
|
</div>
|
|
<el-button type="primary" @click="goBack">
|
|
<el-icon><arrow-left /></el-icon> {{ t('back-to-main') }}
|
|
</el-button>
|
|
</div>
|
|
|
|
<div class="main-container">
|
|
<!-- 左侧面板 -->
|
|
<div class="left-panel">
|
|
<div class="control-section">
|
|
<h4>{{ t('kg-stats') }}</h4>
|
|
<div class="stat-item">
|
|
<span class="stat-label">{{ t('node-count') }}</span>
|
|
<span class="stat-value">{{ stats.nodes }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">{{ t('edge-count') }}</span>
|
|
<span class="stat-value">{{ stats.edges }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">{{ t('node-types') }}</span>
|
|
<span class="stat-value">{{ stats.nodeTypes }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">{{ t('edge-types') }}</span>
|
|
<span class="stat-value">{{ stats.edgeTypes }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="control-section">
|
|
<h4>{{ t('node-search') }}</h4>
|
|
<el-input
|
|
v-model="searchQuery"
|
|
:placeholder="t('enter-gene-name')"
|
|
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>{{ t('relation-filter-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' ? t('activation-induction') :
|
|
category === 'repression' ? t('repression-inhibition') :
|
|
category === 'regulation' ? t('regulation-control') :
|
|
category === 'expression' ? t('expression-detection') :
|
|
category === 'association' ? t('association-interaction') :
|
|
category === 'localization' ? t('localization') :
|
|
category === 'containment' ? t('containment-composition') :
|
|
category === 'participation' ? t('participation-function') : t('other-rel') }}
|
|
</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>{{ t('node-legend-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;">🔗 {{ t('edge-legend-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> {{ t('fit-view') }}
|
|
</el-button>
|
|
<el-button @click="resetFilters">
|
|
<el-icon><refresh /></el-icon> {{ t('reset-filters') }}
|
|
</el-button>
|
|
</el-button-group>
|
|
<el-divider direction="vertical"></el-divider>
|
|
<span style="color: #666; font-size: 13px;">
|
|
{{ t('click-drag-hint') }}
|
|
</span>
|
|
</div>
|
|
<div id="graph-container"></div>
|
|
</div>
|
|
|
|
<!-- 右侧面板 -->
|
|
<div class="right-panel">
|
|
<h4>{{ t('node-detail') }}</h4>
|
|
<div v-if="selectedNode" class="node-detail">
|
|
<div style="margin-bottom: 15px;">
|
|
<div class="label">{{ t('node-name') }}</div>
|
|
<div class="value" style="font-size: 18px; color: #409EFF;">{{ selectedNode.name }}</div>
|
|
</div>
|
|
<div style="margin-bottom: 15px;">
|
|
<div class="label">{{ t('node-type-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">{{ t('connection-count') }}</div>
|
|
<div class="value">{{ selectedNode.degree }}{{ t('num-connections') }}</div>
|
|
</div>
|
|
</div>
|
|
<div v-else style="color: #999; text-align: center; padding: 40px;">
|
|
{{ t('click-node-detail') }}
|
|
</div>
|
|
|
|
<template v-if="selectedNodeRelations.length > 0">
|
|
<h4 style="margin-top: 20px;">🔗 {{ t('related-relations') }} ({{ 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;">
|
|
{{ tp('more-relations', { count: selectedNodeRelations.length - 10 }) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const { createApp, ref, onMounted, computed } = Vue;
|
|
const { ElMessage } = ElementPlus;
|
|
|
|
createApp({
|
|
setup() {
|
|
const t = window.t || ((key) => key);
|
|
const tp = window.tp || ((key, params) => {
|
|
let s = t(key);
|
|
if (params) Object.keys(params).forEach(k => { s = s.replace('{' + k + '}', params[k]); });
|
|
return s;
|
|
});
|
|
document.title = t('kg-title');
|
|
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(t('err-data-format'));
|
|
}
|
|
if (!data.edges || !Array.isArray(data.edges)) {
|
|
console.warn(t('warn-edges-undefined'));
|
|
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(t('msg-kg-loaded'));
|
|
} catch (error) {
|
|
ElMessage.error(t('msg-load-data-failed') + 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/>${t('connection-count').replace('Connections', 'Deg')}: ${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(t('msg-node-found') + node.name);
|
|
} else {
|
|
ElMessage.warning(t('msg-node-not-found'));
|
|
}
|
|
};
|
|
|
|
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 {
|
|
t,
|
|
tp,
|
|
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>
|