1753 lines
65 KiB
HTML
1753 lines
65 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>MoFox Bot - 记忆图可视化</title>
|
||
<script type="text/javascript" src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.container {
|
||
display: flex;
|
||
height: 100vh;
|
||
gap: 10px;
|
||
padding: 10px;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 320px;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.graph-container {
|
||
flex: 1;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
#memory-graph {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.controls {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||
backdrop-filter: blur(10px);
|
||
padding: 15px 20px;
|
||
}
|
||
|
||
h1 {
|
||
color: #333;
|
||
font-size: 24px;
|
||
margin-bottom: 10px;
|
||
border-bottom: 3px solid #667eea;
|
||
padding-bottom: 10px;
|
||
}
|
||
|
||
h2 {
|
||
color: #555;
|
||
font-size: 16px;
|
||
margin-bottom: 10px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.search-box {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.search-box input {
|
||
flex: 1;
|
||
padding: 10px;
|
||
border: 2px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
transition: border-color 0.3s;
|
||
}
|
||
|
||
.search-box input:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.btn {
|
||
padding: 10px 20px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
|
||
.btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.btn:active {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||
}
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 10px;
|
||
}
|
||
|
||
.stat-item {
|
||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #667eea;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.legend-color {
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
border: 2px solid #fff;
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.filter-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px;
|
||
background: #f8f9fa;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.filter-item input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.filter-item label {
|
||
flex: 1;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.performance-mode {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
background: #fff3cd;
|
||
border-radius: 6px;
|
||
border: 1px solid #ffc107;
|
||
}
|
||
|
||
.performance-mode h3 {
|
||
font-size: 13px;
|
||
color: #856404;
|
||
margin: 0;
|
||
}
|
||
|
||
.performance-options {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.performance-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.performance-item input[type="radio"] {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.performance-item label {
|
||
cursor: pointer;
|
||
flex: 1;
|
||
}
|
||
|
||
.performance-tips {
|
||
margin-top: 8px;
|
||
padding: 8px;
|
||
background: #e8f5e9;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
color: #2e7d32;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.performance-tips strong {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.loading {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
text-align: center;
|
||
color: #667eea;
|
||
font-size: 18px;
|
||
font-weight: 500;
|
||
z-index: 1000;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
padding: 30px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||
}
|
||
|
||
.loading-progress {
|
||
margin-top: 10px;
|
||
font-size: 14px;
|
||
color: #764ba2;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 50px;
|
||
height: 50px;
|
||
border: 4px solid #f3f3f3;
|
||
border-top: 4px solid #667eea;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 15px;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
|
||
.info-panel {
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
font-size: 13px;
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.info-panel h3 {
|
||
font-size: 14px;
|
||
color: #667eea;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.info-panel p {
|
||
margin: 4px 0;
|
||
color: #555;
|
||
}
|
||
|
||
.control-buttons {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-buttons .btn {
|
||
flex: 1;
|
||
min-width: 120px;
|
||
}
|
||
|
||
/* 文件选择器模态框 */
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
z-index: 1000;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
backdrop-filter: blur(5px);
|
||
}
|
||
|
||
.modal-content {
|
||
position: relative;
|
||
background: white;
|
||
margin: 5% auto;
|
||
padding: 30px;
|
||
width: 80%;
|
||
max-width: 700px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 2px solid #667eea;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
margin: 0;
|
||
color: #333;
|
||
}
|
||
|
||
.close-btn {
|
||
font-size: 28px;
|
||
font-weight: bold;
|
||
color: #999;
|
||
cursor: pointer;
|
||
transition: color 0.3s;
|
||
}
|
||
|
||
.close-btn:hover {
|
||
color: #333;
|
||
}
|
||
|
||
.file-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.file-item {
|
||
padding: 15px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
border: 2px solid transparent;
|
||
}
|
||
|
||
.file-item:hover {
|
||
background: #e9ecef;
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.file-item.current {
|
||
border-color: #667eea;
|
||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||
}
|
||
|
||
.file-item-name {
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.file-item-info {
|
||
font-size: 12px;
|
||
color: #666;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.file-item-current {
|
||
display: inline-block;
|
||
background: #667eea;
|
||
color: white;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.container {
|
||
flex-direction: column;
|
||
}
|
||
.sidebar {
|
||
width: 100%;
|
||
max-height: 300px;
|
||
}
|
||
.modal-content {
|
||
width: 95%;
|
||
margin: 10% auto;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<!-- 侧边栏 -->
|
||
<div class="sidebar">
|
||
<h1>🦊 记忆图可视化</h1>
|
||
|
||
<!-- 文件选择 -->
|
||
<div>
|
||
<h2>📂 数据文件</h2>
|
||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||
<button class="btn" style="flex: 1;" onclick="showFileSelector()">选择文件</button>
|
||
<button class="btn btn-secondary" style="flex: 1;" onclick="loadFileList()">刷新列表</button>
|
||
</div>
|
||
<div id="currentFileInfo" style="font-size: 12px; color: #666; padding: 8px; background: #f8f9fa; border-radius: 6px;">
|
||
<p>加载中...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索 -->
|
||
<div>
|
||
<h2>🔍 搜索记忆</h2>
|
||
<div class="search-box">
|
||
<input type="text" id="searchInput" placeholder="输入关键词搜索...">
|
||
<button class="btn" onclick="searchMemories()">搜索</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 统计信息 -->
|
||
<div>
|
||
<h2>📊 统计信息</h2>
|
||
<div class="stats">
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="statNodes">0</div>
|
||
<div class="stat-label">节点数</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="statEdges">0</div>
|
||
<div class="stat-label">边数</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="statMemories">0</div>
|
||
<div class="stat-label">记忆数</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-value" id="statDensity">0%</div>
|
||
<div class="stat-label">图密度</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 节点类型图例 -->
|
||
<div>
|
||
<h2>🎨 节点类型</h2>
|
||
<div class="legend">
|
||
<div class="legend-item">
|
||
<div class="legend-color" style="background: #FF6B6B;"></div>
|
||
<span>主体 (SUBJECT)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color" style="background: #4ECDC4;"></div>
|
||
<span>主题 (TOPIC)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color" style="background: #45B7D1;"></div>
|
||
<span>客体 (OBJECT)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color" style="background: #FFA07A;"></div>
|
||
<span>属性 (ATTRIBUTE)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-color" style="background: #98D8C8;"></div>
|
||
<span>值 (VALUE)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 过滤器 -->
|
||
<div>
|
||
<h2>🔧 过滤器</h2>
|
||
<div class="filter-group">
|
||
<div class="filter-item">
|
||
<input type="checkbox" id="filterSubject" checked onchange="applyFilters()">
|
||
<label for="filterSubject">显示主体节点</label>
|
||
</div>
|
||
<div class="filter-item">
|
||
<input type="checkbox" id="filterTopic" checked onchange="applyFilters()">
|
||
<label for="filterTopic">显示主题节点</label>
|
||
</div>
|
||
<div class="filter-item">
|
||
<input type="checkbox" id="filterObject" checked onchange="applyFilters()">
|
||
<label for="filterObject">显示客体节点</label>
|
||
</div>
|
||
<div class="filter-item">
|
||
<input type="checkbox" id="filterAttribute" checked onchange="applyFilters()">
|
||
<label for="filterAttribute">显示属性节点</label>
|
||
</div>
|
||
<div class="filter-item">
|
||
<input type="checkbox" id="filterValue" checked onchange="applyFilters()">
|
||
<label for="filterValue">显示值节点</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 性能模式 -->
|
||
<div>
|
||
<h2>⚡ 性能模式</h2>
|
||
<div class="performance-mode">
|
||
<h3>数据加载模式</h3>
|
||
<div class="performance-options">
|
||
<div class="performance-item">
|
||
<input type="radio" id="modeAuto" name="loadMode" value="auto" checked onchange="changeLoadMode()">
|
||
<label for="modeAuto">自动选择(推荐)</label>
|
||
</div>
|
||
<div class="performance-item">
|
||
<input type="radio" id="modeFull" name="loadMode" value="full" onchange="changeLoadMode()">
|
||
<label for="modeFull">完整加载(<500节点)</label>
|
||
</div>
|
||
<div class="performance-item">
|
||
<input type="radio" id="modeCluster" name="loadMode" value="cluster" onchange="changeLoadMode()">
|
||
<label for="modeCluster">聚类简化(300节点)</label>
|
||
</div>
|
||
<div class="performance-item">
|
||
<input type="radio" id="modePaginated" name="loadMode" value="paginated" onchange="changeLoadMode()">
|
||
<label for="modePaginated">分页加载(500/页)</label>
|
||
</div>
|
||
</div>
|
||
<div class="performance-tips">
|
||
<strong>💡 性能提示:</strong>
|
||
• 节点 >500: 优先使用聚类或分页模式<br>
|
||
• 加载完成后点击"禁用物理"按钮<br>
|
||
• 鼠标悬停可查看节点标签<br>
|
||
• 点击节点高亮关联路径(完整模式1跳)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 选中节点信息 -->
|
||
<div>
|
||
<h2>ℹ️ 节点信息</h2>
|
||
<div class="info-panel" id="nodeInfo">
|
||
<p style="color: #999;">点击节点查看详细信息</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区 -->
|
||
<div class="main-content">
|
||
<!-- 控制按钮 -->
|
||
<div class="controls">
|
||
<div class="control-buttons">
|
||
<button class="btn" onclick="loadGraph()">🔄 刷新图形</button>
|
||
<button class="btn btn-secondary" onclick="fitNetwork()">📐 适应窗口</button>
|
||
<button class="btn" onclick="exportGraph()">💾 导出数据</button>
|
||
<button class="btn" onclick="togglePhysics()" id="physicsToggle">⚙️ 启用物理</button>
|
||
</div>
|
||
<div id="paginationControls" class="control-buttons" style="margin-top: 10px; display: none;">
|
||
<button class="btn" onclick="loadPreviousPage()" id="prevPageBtn">⬅️ 上一页</button>
|
||
<span style="padding: 10px; font-size: 14px; font-weight: bold;" id="pageInfo">第 1 页</span>
|
||
<button class="btn" onclick="loadNextPage()" id="nextPageBtn">下一页 ➡️</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图形显示区 -->
|
||
<div class="graph-container">
|
||
<div id="memory-graph"></div>
|
||
<div class="loading" id="loading">
|
||
<div class="loading-spinner"></div>
|
||
<div>正在加载记忆图...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件选择模态框 -->
|
||
<div id="fileModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>📂 选择数据文件</h2>
|
||
<span class="close-btn" onclick="closeFileSelector()">×</span>
|
||
</div>
|
||
<div id="fileListContainer">
|
||
<div style="text-align: center; padding: 20px;">
|
||
<div class="loading-spinner"></div>
|
||
<p>加载文件列表...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let network = null;
|
||
let availableFiles = [];
|
||
let graphData = {
|
||
nodes: [],
|
||
edges: [],
|
||
memories: []
|
||
};
|
||
let originalData = null;
|
||
let currentLoadMode = 'auto';
|
||
let currentPage = 1;
|
||
let totalPages = 1;
|
||
let isLoading = false;
|
||
let hoveredNodeId = null; // 当前鼠标悬停的节点ID
|
||
let highlightedNodeIds = new Set(); // 当前高亮的节点ID集合
|
||
|
||
// 邻接表缓存,用于高亮优化
|
||
let adjacencyCache = null;
|
||
let nodeDegreeCache = null;
|
||
|
||
// 节点颜色配置
|
||
const nodeColors = {
|
||
'SUBJECT': '#FF6B6B',
|
||
'TOPIC': '#4ECDC4',
|
||
'OBJECT': '#45B7D1',
|
||
'ATTRIBUTE': '#FFA07A',
|
||
'VALUE': '#98D8C8'
|
||
};
|
||
|
||
// 切换加载模式
|
||
function changeLoadMode() {
|
||
const selected = document.querySelector('input[name="loadMode"]:checked');
|
||
if (selected) {
|
||
currentLoadMode = selected.value;
|
||
console.log('切换加载模式:', currentLoadMode);
|
||
loadGraph();
|
||
}
|
||
}
|
||
|
||
// 构建邻接表缓存
|
||
function buildAdjacencyCache(edges) {
|
||
adjacencyCache = new Map();
|
||
nodeDegreeCache = new Map();
|
||
|
||
edges.forEach(edge => {
|
||
const from = edge.from;
|
||
const to = edge.to;
|
||
|
||
if (!adjacencyCache.has(from)) {
|
||
adjacencyCache.set(from, []);
|
||
nodeDegreeCache.set(from, 0);
|
||
}
|
||
if (!adjacencyCache.has(to)) {
|
||
adjacencyCache.set(to, []);
|
||
nodeDegreeCache.set(to, 0);
|
||
}
|
||
|
||
adjacencyCache.get(from).push({ nodeId: to, edgeId: edge.id });
|
||
adjacencyCache.get(to).push({ nodeId: from, edgeId: edge.id });
|
||
nodeDegreeCache.set(from, nodeDegreeCache.get(from) + 1);
|
||
nodeDegreeCache.set(to, nodeDegreeCache.get(to) + 1);
|
||
});
|
||
|
||
console.log(`邻接表缓存构建完成: ${adjacencyCache.size} 个节点`);
|
||
}
|
||
|
||
// 初始化图形
|
||
function initNetwork() {
|
||
const container = document.getElementById('memory-graph');
|
||
|
||
// 动态配置:大数据集使用极简配置
|
||
const getNetworkOptions = (nodeCount) => {
|
||
const isLargeDataset = nodeCount > 500;
|
||
const isVeryLargeDataset = nodeCount > 2000;
|
||
|
||
return {
|
||
nodes: {
|
||
shape: 'dot',
|
||
size: isVeryLargeDataset ? 10 : (isLargeDataset ? 15 : 20),
|
||
font: {
|
||
size: isVeryLargeDataset ? 0 : (isLargeDataset ? 10 : 14), // 超大数据集隐藏文字
|
||
color: '#333',
|
||
face: 'Microsoft YaHei'
|
||
},
|
||
borderWidth: isVeryLargeDataset ? 0 : 2,
|
||
borderWidthSelected: 3,
|
||
shadow: false, // 始终禁用阴影
|
||
scaling: {
|
||
min: isVeryLargeDataset ? 5 : 10,
|
||
max: isVeryLargeDataset ? 15 : 30
|
||
}
|
||
},
|
||
edges: {
|
||
width: isVeryLargeDataset ? 0.5 : (isLargeDataset ? 1 : 2),
|
||
color: {
|
||
color: '#848484',
|
||
highlight: '#667eea',
|
||
hover: '#764ba2',
|
||
opacity: isVeryLargeDataset ? 0.3 : 0.7 // 超大数据集边半透明
|
||
},
|
||
arrows: {
|
||
to: {
|
||
enabled: !isVeryLargeDataset, // 超大数据集禁用箭头
|
||
scaleFactor: 0.3
|
||
}
|
||
},
|
||
smooth: false, // 始终禁用平滑以提升性能
|
||
font: {
|
||
size: 0, // 始终隐藏边标签
|
||
strokeWidth: 0
|
||
},
|
||
shadow: false,
|
||
selectionWidth: 2
|
||
},
|
||
physics: {
|
||
enabled: true,
|
||
barnesHut: {
|
||
gravitationalConstant: isVeryLargeDataset ? -800 : (isLargeDataset ? -2000 : -8000),
|
||
centralGravity: isVeryLargeDataset ? 0.1 : 0.3,
|
||
springLength: isVeryLargeDataset ? 50 : (isLargeDataset ? 95 : 150),
|
||
springConstant: isVeryLargeDataset ? 0.001 : (isLargeDataset ? 0.02 : 0.04),
|
||
damping: isVeryLargeDataset ? 0.3 : 0.09,
|
||
avoidOverlap: 0
|
||
},
|
||
stabilization: {
|
||
enabled: true,
|
||
iterations: isVeryLargeDataset ? 50 : (isLargeDataset ? 100 : 300),
|
||
updateInterval: isVeryLargeDataset ? 100 : (isLargeDataset ? 50 : 25),
|
||
onlyDynamicEdges: false,
|
||
fit: true
|
||
},
|
||
solver: 'barnesHut',
|
||
timestep: isVeryLargeDataset ? 1.0 : 0.5,
|
||
adaptiveTimestep: true,
|
||
maxVelocity: isVeryLargeDataset ? 100 : 50,
|
||
minVelocity: isVeryLargeDataset ? 5 : 0.75
|
||
},
|
||
interaction: {
|
||
hover: true, // 始终启用hover - 我们用自定义事件处理标签显示
|
||
tooltipDelay: 300,
|
||
zoomView: true,
|
||
dragView: true,
|
||
hideEdgesOnDrag: true, // 始终在拖拽时隐藏边
|
||
hideEdgesOnZoom: true, // 始终在缩放时隐藏边
|
||
hideNodesOnDrag: isVeryLargeDataset, // 超大数据集拖拽时也隐藏节点
|
||
navigationButtons: false,
|
||
keyboard: false
|
||
},
|
||
layout: {
|
||
improvedLayout: !isVeryLargeDataset, // 超大数据集禁用改进布局
|
||
randomSeed: 2 // 固定随机种子以获得一致的布局
|
||
}
|
||
};
|
||
};
|
||
|
||
const data = {
|
||
nodes: new vis.DataSet([]),
|
||
edges: new vis.DataSet([])
|
||
};
|
||
|
||
// 初始使用中等配置
|
||
network = new vis.Network(container, data, getNetworkOptions(500));
|
||
|
||
// 保存配置函数供后续使用
|
||
network.updateOptions = (nodeCount) => {
|
||
network.setOptions(getNetworkOptions(nodeCount));
|
||
};
|
||
|
||
// 添加事件监听
|
||
network.on('click', function(params) {
|
||
if (params.nodes.length > 0) {
|
||
const nodeId = params.nodes[0];
|
||
showNodeInfo(nodeId);
|
||
highlightConnectedNodes(nodeId);
|
||
} else {
|
||
// 点击空白处,恢复所有节点
|
||
resetNodeHighlight();
|
||
}
|
||
});
|
||
|
||
// 添加鼠标悬停事件(仅在小数据集启用)
|
||
network.on('hoverNode', function(params) {
|
||
hoveredNodeId = params.node;
|
||
updateNodeLabel(params.node, true); // 显示悬停节点的标签
|
||
});
|
||
|
||
network.on('blurNode', function(params) {
|
||
// 如果节点不在高亮集合中,隐藏标签
|
||
if (!highlightedNodeIds.has(params.node)) {
|
||
updateNodeLabel(params.node, false);
|
||
}
|
||
hoveredNodeId = null;
|
||
});
|
||
|
||
// 稳定化完成后停止物理引擎
|
||
network.on('stabilizationIterationsDone', function() {
|
||
console.log('稳定化完成,停止物理引擎');
|
||
network.setOptions({ physics: { enabled: false } });
|
||
updateLoadingProgress('布局完成');
|
||
});
|
||
|
||
// 添加稳定化进度监听
|
||
network.on('stabilizationProgress', function(params) {
|
||
const progress = Math.round((params.iterations / params.total) * 100);
|
||
updateLoadingProgress(`布局中: ${progress}%`);
|
||
});
|
||
}
|
||
|
||
// 更新加载进度提示
|
||
function updateLoadingProgress(message) {
|
||
const progressDiv = document.querySelector('.loading-progress');
|
||
if (progressDiv) {
|
||
progressDiv.textContent = message;
|
||
} else {
|
||
const loadingDiv = document.getElementById('loading');
|
||
if (loadingDiv) {
|
||
const existing = loadingDiv.querySelector('.loading-progress');
|
||
if (!existing) {
|
||
const newDiv = document.createElement('div');
|
||
newDiv.className = 'loading-progress';
|
||
newDiv.textContent = message;
|
||
loadingDiv.appendChild(newDiv);
|
||
} else {
|
||
existing.textContent = message;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 动态更新节点标签显示
|
||
function updateNodeLabel(nodeId, show) {
|
||
if (!network || !graphData) return;
|
||
|
||
const nodes = network.body.data.nodes;
|
||
const node = nodes.get(nodeId);
|
||
if (!node) return;
|
||
|
||
// 获取原始节点数据
|
||
const originalNode = graphData.nodes.find(n => n.id === nodeId);
|
||
if (!originalNode) return;
|
||
|
||
// 更新节点标签
|
||
nodes.update({
|
||
id: nodeId,
|
||
label: show ? originalNode.label : '',
|
||
font: show ? { size: 14, color: '#333' } : { size: 0 }
|
||
});
|
||
}
|
||
|
||
// 批量更新边标签显示
|
||
function updateEdgeLabels(edgeIds, show) {
|
||
if (!network || !graphData || edgeIds.size === 0) return;
|
||
|
||
const edges = network.body.data.edges;
|
||
const edgeUpdates = [];
|
||
|
||
edgeIds.forEach(edgeId => {
|
||
const edge = edges.get(edgeId);
|
||
if (!edge) return;
|
||
|
||
// 获取原始边数据
|
||
const originalEdge = graphData.edges.find(e => e.id === edgeId);
|
||
if (!originalEdge) return;
|
||
|
||
edgeUpdates.push({
|
||
id: edgeId,
|
||
label: show ? (originalEdge.label || '') : '',
|
||
font: show ? { size: 11, color: '#666' } : { size: 0 }
|
||
});
|
||
});
|
||
|
||
if (edgeUpdates.length > 0) {
|
||
edges.update(edgeUpdates);
|
||
}
|
||
}
|
||
|
||
// 加载图形数据(智能选择模式)
|
||
async function loadGraph() {
|
||
if (isLoading) {
|
||
console.log('已有加载任务在进行中');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
isLoading = true;
|
||
document.getElementById('loading').style.display = 'block';
|
||
updateLoadingProgress('获取数据摘要...');
|
||
|
||
// 首先获取摘要信息以决定加载策略
|
||
const summaryResponse = await fetch('/visualizer/api/graph/summary');
|
||
const summaryResult = await summaryResponse.json();
|
||
|
||
if (!summaryResult.success) {
|
||
throw new Error(summaryResult.error);
|
||
}
|
||
|
||
const totalNodes = summaryResult.data.stats.total_nodes;
|
||
console.log(`图数据节点总数: ${totalNodes}`);
|
||
|
||
// 根据模式和数据量选择加载策略(更保守的阈值)
|
||
let loadMode = currentLoadMode;
|
||
if (loadMode === 'auto') {
|
||
if (totalNodes <= 500) {
|
||
loadMode = 'full';
|
||
} else if (totalNodes <= 2000) {
|
||
loadMode = 'cluster';
|
||
} else {
|
||
loadMode = 'paginated';
|
||
}
|
||
console.log(`自动选择加载模式: ${loadMode} (节点数: ${totalNodes})`);
|
||
}
|
||
|
||
// 根据选择的模式加载数据
|
||
let result;
|
||
if (loadMode === 'full') {
|
||
updateLoadingProgress('加载完整数据...');
|
||
result = await loadFullGraph();
|
||
} else if (loadMode === 'cluster') {
|
||
updateLoadingProgress('加载聚类数据...');
|
||
result = await loadClusteredGraph();
|
||
} else if (loadMode === 'paginated') {
|
||
updateLoadingProgress('加载分页数据 (第1页)...');
|
||
result = await loadPaginatedGraph(1);
|
||
}
|
||
|
||
if (result && result.success) {
|
||
updateLoadingProgress('渲染图形...');
|
||
originalData = result.data;
|
||
updateGraph(result.data);
|
||
updateStats(result.data.stats);
|
||
|
||
// 构建缓存以优化后续操作
|
||
buildAdjacencyCache(result.data.edges || []);
|
||
|
||
// 显示/隐藏分页控制
|
||
const paginationControls = document.getElementById('paginationControls');
|
||
if (result.data.pagination) {
|
||
const p = result.data.pagination;
|
||
totalPages = p.total_pages;
|
||
currentPage = p.page;
|
||
paginationControls.style.display = 'flex';
|
||
document.getElementById('pageInfo').textContent = `第 ${p.page}/${p.total_pages} 页`;
|
||
document.getElementById('prevPageBtn').disabled = !p.has_prev;
|
||
document.getElementById('nextPageBtn').disabled = !p.has_next;
|
||
} else {
|
||
paginationControls.style.display = 'none';
|
||
}
|
||
|
||
// 显示加载信息
|
||
if (result.data.clustered) {
|
||
console.log(`✅ 聚类简化: ${result.data.stats.original_nodes} → ${result.data.stats.clustered_nodes} 节点`);
|
||
} else if (result.data.pagination) {
|
||
const p = result.data.pagination;
|
||
console.log(`✅ 分页加载: 第 ${p.page}/${p.total_pages} 页 (共 ${p.total_nodes} 节点)`);
|
||
} else {
|
||
console.log(`✅ 完整加载: ${result.data.stats.total_nodes} 节点`);
|
||
}
|
||
} else {
|
||
alert('加载失败: ' + (result ? result.error : '未知错误'));
|
||
}
|
||
} catch (error) {
|
||
console.error('加载图形失败:', error);
|
||
alert('加载失败: ' + error.message);
|
||
} finally {
|
||
isLoading = false;
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 加载完整图数据
|
||
async function loadFullGraph() {
|
||
const response = await fetch('/visualizer/api/graph/full');
|
||
return await response.json();
|
||
}
|
||
|
||
// 加载聚类简化数据
|
||
async function loadClusteredGraph(maxNodes = 300) {
|
||
const response = await fetch(`/visualizer/api/graph/clustered?max_nodes=${maxNodes}&cluster_threshold=10`);
|
||
return await response.json();
|
||
}
|
||
|
||
// 加载分页数据
|
||
async function loadPaginatedGraph(page = 1, pageSize = 500) {
|
||
const response = await fetch(`/visualizer/api/graph/paginated?page=${page}&page_size=${pageSize}&min_importance=0.0`);
|
||
return await response.json();
|
||
}
|
||
|
||
// 更新图形显示(优化版本 - 使用节点限制和延迟渲染)
|
||
function updateGraph(data) {
|
||
graphData = data;
|
||
const nodeCount = data.nodes.length;
|
||
const edgeCount = data.edges.length;
|
||
|
||
console.log(`准备更新图形: ${nodeCount} 个节点, ${edgeCount} 条边`);
|
||
|
||
// 对于超大数据集,进一步限制
|
||
const MAX_RENDERABLE_NODES = 5000;
|
||
const MAX_RENDERABLE_EDGES = 10000;
|
||
|
||
let nodesToRender = data.nodes;
|
||
let edgesToRender = data.edges;
|
||
let isLimited = false;
|
||
|
||
// 如果超过限制,只渲染最重要的节点
|
||
if (nodeCount > MAX_RENDERABLE_NODES) {
|
||
console.warn(`节点数 ${nodeCount} 超过渲染限制 ${MAX_RENDERABLE_NODES},将只显示最重要的节点`);
|
||
|
||
// 计算节点重要性
|
||
const nodeDegrees = new Map();
|
||
data.edges.forEach(edge => {
|
||
nodeDegrees.set(edge.from, (nodeDegrees.get(edge.from) || 0) + 1);
|
||
nodeDegrees.set(edge.to, (nodeDegrees.get(edge.to) || 0) + 1);
|
||
});
|
||
|
||
// 按连接度排序,保留前N个
|
||
nodesToRender = data.nodes
|
||
.map(node => ({
|
||
...node,
|
||
degree: nodeDegrees.get(node.id) || 0
|
||
}))
|
||
.sort((a, b) => b.degree - a.degree)
|
||
.slice(0, MAX_RENDERABLE_NODES);
|
||
|
||
const renderableNodeIds = new Set(nodesToRender.map(n => n.id));
|
||
edgesToRender = data.edges.filter(e =>
|
||
renderableNodeIds.has(e.from) && renderableNodeIds.has(e.to)
|
||
);
|
||
|
||
isLimited = true;
|
||
alert(`⚠️ 数据量过大\n原始: ${nodeCount} 节点\n仅渲染: ${nodesToRender.length} 个最重要节点\n建议使用"聚类简化"或"分页加载"模式`);
|
||
}
|
||
|
||
// 如果边数过多,也进行限制
|
||
if (edgesToRender.length > MAX_RENDERABLE_EDGES) {
|
||
console.warn(`边数 ${edgesToRender.length} 超过渲染限制 ${MAX_RENDERABLE_EDGES}`);
|
||
edgesToRender = edgesToRender.slice(0, MAX_RENDERABLE_EDGES);
|
||
isLimited = true;
|
||
}
|
||
|
||
// 根据节点数量动态调整网络配置
|
||
if (network.updateOptions) {
|
||
network.updateOptions(nodesToRender.length);
|
||
}
|
||
|
||
console.log(`实际渲染: ${nodesToRender.length} 个节点, ${edgesToRender.length} 条边`);
|
||
|
||
// 极简化节点数据 - 移除所有不必要的属性
|
||
const startTime = performance.now();
|
||
const nodes = nodesToRender.map(node => {
|
||
const baseNode = {
|
||
id: node.id,
|
||
label: '', // 默认不显示标签,仅在悬停或高亮时显示
|
||
group: node.group,
|
||
color: nodeColors[node.group] || '#999'
|
||
};
|
||
|
||
// 仅在小数据集中添加title(tooltip)
|
||
if (nodesToRender.length <= 1000) {
|
||
baseNode.title = node.title || node.label;
|
||
}
|
||
|
||
// 如果是聚类节点,使用不同的样式并始终显示标签
|
||
if (node.is_cluster) {
|
||
baseNode.shape = 'star';
|
||
baseNode.size = 25 + Math.min(node.cluster_size / 10, 15);
|
||
baseNode.label = `${node.group} (${node.cluster_size})`;
|
||
baseNode.font = { size: 14, bold: true };
|
||
}
|
||
|
||
return baseNode;
|
||
});
|
||
|
||
// 极简化边数据
|
||
const edges = edgesToRender.map(edge => {
|
||
const baseEdge = {
|
||
id: edge.id,
|
||
from: edge.from,
|
||
to: edge.to,
|
||
label: '' // 默认不显示标签,仅在高亮时显示
|
||
};
|
||
|
||
// 在小数据集中保留边宽度
|
||
if (edgesToRender.length <= 1000) {
|
||
baseEdge.width = (edge.importance || 0.5) * 2 + 0.5;
|
||
}
|
||
|
||
return baseEdge;
|
||
});
|
||
|
||
// 批量更新网络(使用异步渲染以避免阻塞UI)
|
||
const prepTime = performance.now() - startTime;
|
||
console.log(`数据准备耗时: ${prepTime.toFixed(2)}ms`);
|
||
|
||
// 使用 requestAnimationFrame 异步渲染,避免阻塞UI
|
||
requestAnimationFrame(() => {
|
||
const renderStart = performance.now();
|
||
|
||
// 先清空现有数据
|
||
network.setData({
|
||
nodes: new vis.DataSet([]),
|
||
edges: new vis.DataSet([])
|
||
});
|
||
|
||
// 分批添加节点和边以提升响应性
|
||
const BATCH_SIZE = 500;
|
||
let nodeIndex = 0;
|
||
let edgeIndex = 0;
|
||
|
||
const nodeDataSet = new vis.DataSet();
|
||
const edgeDataSet = new vis.DataSet();
|
||
|
||
function addNodeBatch() {
|
||
const batch = nodes.slice(nodeIndex, nodeIndex + BATCH_SIZE);
|
||
if (batch.length > 0) {
|
||
nodeDataSet.add(batch);
|
||
nodeIndex += BATCH_SIZE;
|
||
updateLoadingProgress(`加载节点: ${Math.min(nodeIndex, nodes.length)}/${nodes.length}`);
|
||
|
||
if (nodeIndex < nodes.length) {
|
||
setTimeout(addNodeBatch, 0);
|
||
} else {
|
||
// 节点加载完成,开始加载边
|
||
setTimeout(addEdgeBatch, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
function addEdgeBatch() {
|
||
const batch = edges.slice(edgeIndex, edgeIndex + BATCH_SIZE);
|
||
if (batch.length > 0) {
|
||
edgeDataSet.add(batch);
|
||
edgeIndex += BATCH_SIZE;
|
||
updateLoadingProgress(`加载边: ${Math.min(edgeIndex, edges.length)}/${edges.length}`);
|
||
|
||
if (edgeIndex < edges.length) {
|
||
setTimeout(addEdgeBatch, 0);
|
||
} else {
|
||
// 所有数据加载完成,更新网络
|
||
finishRendering();
|
||
}
|
||
}
|
||
}
|
||
|
||
function finishRendering() {
|
||
network.setData({
|
||
nodes: nodeDataSet,
|
||
edges: edgeDataSet
|
||
});
|
||
|
||
const renderTime = performance.now() - renderStart;
|
||
console.log(`图形渲染总耗时: ${renderTime.toFixed(2)}ms`);
|
||
updateLoadingProgress('渲染完成');
|
||
}
|
||
|
||
// 对于小数据集,直接一次性加载
|
||
if (nodes.length <= 1000) {
|
||
nodeDataSet.add(nodes);
|
||
edgeDataSet.add(edges);
|
||
network.setData({
|
||
nodes: nodeDataSet,
|
||
edges: edgeDataSet
|
||
});
|
||
const renderTime = performance.now() - renderStart;
|
||
console.log(`图形渲染总耗时: ${renderTime.toFixed(2)}ms`);
|
||
} else {
|
||
// 大数据集使用分批加载
|
||
addNodeBatch();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 更新统计信息
|
||
function updateStats(stats) {
|
||
document.getElementById('statNodes').textContent = stats.total_nodes;
|
||
document.getElementById('statEdges').textContent = stats.total_edges;
|
||
document.getElementById('statMemories').textContent = stats.total_memories;
|
||
|
||
const density = stats.total_nodes > 0
|
||
? ((stats.total_edges / (stats.total_nodes * (stats.total_nodes - 1))) * 100).toFixed(2)
|
||
: 0;
|
||
document.getElementById('statDensity').textContent = density + '%';
|
||
}
|
||
|
||
// 显示节点信息
|
||
function showNodeInfo(nodeId) {
|
||
const node = graphData.nodes.find(n => n.id === nodeId);
|
||
if (!node) return;
|
||
|
||
const infoPanel = document.getElementById('nodeInfo');
|
||
infoPanel.innerHTML = `
|
||
<h3>${node.label}</h3>
|
||
<p><strong>类型:</strong> ${node.type}</p>
|
||
<p><strong>ID:</strong> ${node.id.substring(0, 8)}...</p>
|
||
<p><strong>创建时间:</strong> ${new Date(node.created_at).toLocaleString('zh-CN')}</p>
|
||
${node.metadata && Object.keys(node.metadata).length > 0
|
||
? `<p><strong>元数据:</strong> ${JSON.stringify(node.metadata, null, 2)}</p>`
|
||
: ''}
|
||
`;
|
||
}
|
||
|
||
// 搜索记忆
|
||
async function searchMemories() {
|
||
const query = document.getElementById('searchInput').value;
|
||
if (!query) {
|
||
alert('请输入搜索关键词');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`api/search?q=${encodeURIComponent(query)}&limit=50`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
highlightSearchResults(result.data.results);
|
||
} else {
|
||
alert('搜索失败: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('搜索失败:', error);
|
||
alert('搜索失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 高亮搜索结果
|
||
function highlightSearchResults(results) {
|
||
const memoryIds = results.map(r => r.id);
|
||
const relatedNodeIds = new Set();
|
||
|
||
// 找出相关的节点
|
||
graphData.memories.forEach(memory => {
|
||
if (memoryIds.includes(memory.id)) {
|
||
// 这里需要找到该记忆相关的所有节点
|
||
// 简化实现:高亮所有节点
|
||
graphData.nodes.forEach(node => relatedNodeIds.add(node.id));
|
||
}
|
||
});
|
||
|
||
// 高亮节点
|
||
if (relatedNodeIds.size > 0) {
|
||
network.selectNodes([...relatedNodeIds]);
|
||
network.fit({
|
||
nodes: [...relatedNodeIds],
|
||
animation: true
|
||
});
|
||
} else {
|
||
alert('未找到相关节点');
|
||
}
|
||
}
|
||
|
||
// 高亮与选中节点连接的节点(优化版本,使用缓存)
|
||
function highlightConnectedNodes(nodeId) {
|
||
if (!network || !graphData || !adjacencyCache) return;
|
||
|
||
const startTime = performance.now();
|
||
|
||
// 根据加载模式和数据规模决定探索深度
|
||
// 完整加载模式:1跳
|
||
// 聚类/分页模式:3跳(因为数据量已经减少)
|
||
const nodeCount = graphData.nodes.length;
|
||
let MAX_DEPTH = 3;
|
||
|
||
if (currentLoadMode === 'full' || (currentLoadMode === 'auto' && nodeCount <= 500)) {
|
||
MAX_DEPTH = 1; // 完整加载模式限制为1跳
|
||
console.log('完整加载模式:使用1跳探索深度');
|
||
} else {
|
||
console.log('聚类/分页模式:使用3跳探索深度');
|
||
}
|
||
|
||
// 使用缓存的邻接表进行 BFS
|
||
const connectedNodeIds = new Set();
|
||
const connectedEdgeIds = new Set();
|
||
const visited = new Set();
|
||
const queue = [{ nodeId: nodeId, depth: 0 }];
|
||
|
||
// BFS 遍历
|
||
while (queue.length > 0) {
|
||
const { nodeId: currentNode, depth } = queue.shift();
|
||
|
||
if (visited.has(currentNode)) continue;
|
||
visited.add(currentNode);
|
||
connectedNodeIds.add(currentNode);
|
||
|
||
if (depth >= MAX_DEPTH) continue;
|
||
|
||
// 使用缓存的邻接表
|
||
const neighbors = adjacencyCache.get(currentNode) || [];
|
||
neighbors.forEach(({ nodeId: neighborId, edgeId }) => {
|
||
connectedEdgeIds.add(edgeId);
|
||
if (!visited.has(neighborId)) {
|
||
queue.push({ nodeId: neighborId, depth: depth + 1 });
|
||
}
|
||
});
|
||
}
|
||
|
||
const searchTime = performance.now() - startTime;
|
||
console.log(`BFS搜索耗时: ${searchTime.toFixed(2)}ms, 找到 ${connectedNodeIds.size} 个连接节点 (深度${MAX_DEPTH}跳)`);
|
||
|
||
// 更新高亮节点集合
|
||
highlightedNodeIds = connectedNodeIds;
|
||
|
||
// 批量更新节点和边
|
||
const allNodes = network.body.data.nodes;
|
||
const allEdges = network.body.data.edges;
|
||
const nodeUpdates = [];
|
||
const edgeUpdates = [];
|
||
|
||
// 更新节点
|
||
allNodes.get().forEach(node => {
|
||
const originalNode = graphData.nodes.find(n => n.id === node.id);
|
||
if (!originalNode) return;
|
||
|
||
if (connectedNodeIds.has(node.id)) {
|
||
const isSelected = node.id === nodeId;
|
||
nodeUpdates.push({
|
||
id: node.id,
|
||
opacity: 1.0,
|
||
borderWidth: isSelected ? 5 : 3,
|
||
label: originalNode.label || '', // 显示高亮节点的标签
|
||
font: {
|
||
color: isSelected ? '#667eea' : '#333',
|
||
size: isSelected ? 16 : 14,
|
||
bold: true
|
||
}
|
||
});
|
||
} else {
|
||
const originalColor = nodeColors[node.group] || '#999';
|
||
const dimmedColor = hexToRgba(originalColor, 0.08);
|
||
nodeUpdates.push({
|
||
id: node.id,
|
||
color: {
|
||
background: dimmedColor,
|
||
border: dimmedColor,
|
||
},
|
||
opacity: 0.08,
|
||
label: '', // 隐藏非高亮节点的标签
|
||
font: { color: 'rgba(51, 51, 51, 0.08)', size: 0 }
|
||
});
|
||
}
|
||
});
|
||
|
||
// 更新边
|
||
allEdges.get().forEach(edge => {
|
||
const originalEdge = graphData.edges.find(e => e.id === edge.id);
|
||
|
||
if (connectedEdgeIds.has(edge.id)) {
|
||
edgeUpdates.push({
|
||
id: edge.id,
|
||
color: { color: '#667eea', opacity: 1.0 },
|
||
width: 4,
|
||
label: originalEdge?.label || '', // 显示高亮边的标签
|
||
font: { color: '#667eea', size: 12 }
|
||
});
|
||
} else {
|
||
edgeUpdates.push({
|
||
id: edge.id,
|
||
color: { color: '#848484', opacity: 0.03 },
|
||
width: 1,
|
||
label: '', // 隐藏非高亮边的标签
|
||
font: { color: 'rgba(102, 102, 102, 0.03)', size: 0 }
|
||
});
|
||
}
|
||
});
|
||
|
||
// 批量应用更新
|
||
allNodes.update(nodeUpdates);
|
||
allEdges.update(edgeUpdates);
|
||
|
||
const totalTime = performance.now() - startTime;
|
||
console.log(`高亮操作总耗时: ${totalTime.toFixed(2)}ms`);
|
||
|
||
// 聚焦视图
|
||
if (connectedNodeIds.size > 1 && connectedNodeIds.size < 100) {
|
||
network.fit({
|
||
nodes: Array.from(connectedNodeIds),
|
||
animation: {
|
||
duration: 800,
|
||
easingFunction: 'easeInOutQuad'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 重置节点高亮状态(优化版本)
|
||
function resetNodeHighlight() {
|
||
if (!network || !graphData) return;
|
||
|
||
const startTime = performance.now();
|
||
const allNodes = network.body.data.nodes;
|
||
const allEdges = network.body.data.edges;
|
||
|
||
// 清空高亮节点集合
|
||
highlightedNodeIds.clear();
|
||
|
||
// 批量恢复所有节点(不显示标签,除非是悬停节点)
|
||
const nodeUpdates = allNodes.get().map(node => {
|
||
const originalColor = nodeColors[node.group] || '#999';
|
||
const shouldShowLabel = node.id === hoveredNodeId; // 只显示悬停节点的标签
|
||
const originalNode = shouldShowLabel ? graphData.nodes.find(n => n.id === node.id) : null;
|
||
|
||
return {
|
||
id: node.id,
|
||
color: originalColor,
|
||
opacity: 1.0,
|
||
borderWidth: 2,
|
||
label: shouldShowLabel && originalNode ? originalNode.label : '',
|
||
font: { color: '#333', size: shouldShowLabel ? 14 : 0, bold: false }
|
||
};
|
||
});
|
||
allNodes.update(nodeUpdates);
|
||
|
||
// 批量恢复所有边(不显示标签)
|
||
const edgeUpdates = allEdges.get().map(edge => ({
|
||
id: edge.id,
|
||
color: { color: '#848484', opacity: 1.0 },
|
||
width: 2,
|
||
label: '',
|
||
font: { color: '#666', size: 0 }
|
||
}));
|
||
allEdges.update(edgeUpdates);
|
||
|
||
const endTime = performance.now();
|
||
console.log(`重置高亮耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||
}
|
||
|
||
// 辅助函数:将十六进制颜色转换为 rgba
|
||
function hexToRgba(hex, alpha) {
|
||
// 如果已经是 rgba 格式,直接返回
|
||
if (hex.startsWith('rgba')) return hex;
|
||
|
||
// 处理 # 开头的十六进制
|
||
const r = parseInt(hex.slice(1, 3), 16);
|
||
const g = parseInt(hex.slice(3, 5), 16);
|
||
const b = parseInt(hex.slice(5, 7), 16);
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
|
||
// 应用过滤器(优化版本)
|
||
function applyFilters() {
|
||
if (!originalData) return;
|
||
|
||
const filters = {
|
||
'SUBJECT': document.getElementById('filterSubject').checked,
|
||
'TOPIC': document.getElementById('filterTopic').checked,
|
||
'OBJECT': document.getElementById('filterObject').checked,
|
||
'ATTRIBUTE': document.getElementById('filterAttribute').checked,
|
||
'VALUE': document.getElementById('filterValue').checked
|
||
};
|
||
|
||
// 过滤节点
|
||
const filteredNodes = originalData.nodes.filter(node =>
|
||
filters[node.group]
|
||
);
|
||
const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
|
||
|
||
// 过滤边(只保留两端节点都存在的边)
|
||
const filteredEdges = originalData.edges.filter(edge =>
|
||
filteredNodeIds.has(edge.from) && filteredNodeIds.has(edge.to)
|
||
);
|
||
|
||
// 更新图形
|
||
updateGraph({
|
||
nodes: filteredNodes,
|
||
edges: filteredEdges,
|
||
memories: originalData.memories,
|
||
stats: {
|
||
total_nodes: filteredNodes.length,
|
||
total_edges: filteredEdges.length,
|
||
total_memories: originalData.stats.total_memories
|
||
}
|
||
});
|
||
|
||
// 重置高亮状态
|
||
resetNodeHighlight();
|
||
|
||
// 重建缓存
|
||
buildAdjacencyCache(filteredEdges);
|
||
|
||
// 重新启用物理引擎以重新布局
|
||
if (network && filteredNodes.length > 0) {
|
||
network.setOptions({ physics: { enabled: true } });
|
||
|
||
// 超时保护
|
||
let stabilized = false;
|
||
const timeout = setTimeout(() => {
|
||
if (!stabilized) {
|
||
console.log('物理引擎稳定超时,强制停止');
|
||
network.setOptions({ physics: { enabled: false } });
|
||
}
|
||
}, 5000);
|
||
|
||
network.once('stabilizationIterationsDone', function() {
|
||
stabilized = true;
|
||
clearTimeout(timeout);
|
||
network.setOptions({ physics: { enabled: false } });
|
||
console.log('过滤后重新布局完成');
|
||
});
|
||
}
|
||
}
|
||
|
||
// 适应窗口
|
||
function fitNetwork() {
|
||
if (network) {
|
||
network.fit({
|
||
animation: {
|
||
duration: 1000,
|
||
easingFunction: 'easeInOutQuad'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 切换物理引擎
|
||
function togglePhysics() {
|
||
if (!network) return;
|
||
|
||
const currentPhysics = network.physics.options.enabled;
|
||
network.setOptions({ physics: { enabled: !currentPhysics } });
|
||
|
||
const btn = document.getElementById('physicsToggle');
|
||
btn.textContent = currentPhysics ? '⚙️ 启用物理' : '⏸️ 禁用物理';
|
||
|
||
console.log(`物理引擎: ${currentPhysics ? '已禁用' : '已启用'}`);
|
||
}
|
||
|
||
// 加载下一页
|
||
async function loadNextPage() {
|
||
if (currentPage < totalPages && !isLoading) {
|
||
currentPage++;
|
||
await loadPaginatedGraphAndUpdate();
|
||
}
|
||
}
|
||
|
||
// 加载上一页
|
||
async function loadPreviousPage() {
|
||
if (currentPage > 1 && !isLoading) {
|
||
currentPage--;
|
||
await loadPaginatedGraphAndUpdate();
|
||
}
|
||
}
|
||
|
||
// 加载分页并更新UI
|
||
async function loadPaginatedGraphAndUpdate() {
|
||
try {
|
||
isLoading = true;
|
||
document.getElementById('loading').style.display = 'block';
|
||
updateLoadingProgress(`加载第 ${currentPage} 页...`);
|
||
|
||
const result = await loadPaginatedGraph(currentPage);
|
||
|
||
if (result.success) {
|
||
originalData = result.data;
|
||
updateGraph(result.data);
|
||
updateStats(result.data.stats);
|
||
buildAdjacencyCache(result.data.edges || []);
|
||
|
||
// 更新分页UI
|
||
const p = result.data.pagination;
|
||
totalPages = p.total_pages;
|
||
document.getElementById('pageInfo').textContent = `第 ${p.page}/${p.total_pages} 页`;
|
||
document.getElementById('prevPageBtn').disabled = !p.has_prev;
|
||
document.getElementById('nextPageBtn').disabled = !p.has_next;
|
||
}
|
||
} catch (error) {
|
||
console.error('加载分页失败:', error);
|
||
alert('加载失败: ' + error.message);
|
||
} finally {
|
||
isLoading = false;
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 导出图形数据
|
||
function exportGraph() {
|
||
const dataStr = JSON.stringify(graphData, null, 2);
|
||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||
const url = URL.createObjectURL(dataBlob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `memory_graph_${new Date().getTime()}.json`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// 文件选择功能
|
||
async function loadFileList() {
|
||
try {
|
||
const response = await fetch('/visualizer/api/files');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
availableFiles = result.files;
|
||
updateCurrentFileInfo(result.current_file, result.files);
|
||
return result.files;
|
||
} else {
|
||
console.error('加载文件列表失败:', result.error);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
console.error('加载文件列表失败:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function updateCurrentFileInfo(currentFile, files) {
|
||
const infoDiv = document.getElementById('currentFileInfo');
|
||
|
||
if (!currentFile || files.length === 0) {
|
||
infoDiv.innerHTML = '<p style="color: #e74c3c;">❌ 未找到数据文件</p>';
|
||
return;
|
||
}
|
||
|
||
const currentFileObj = files.find(f => f.path === currentFile);
|
||
if (currentFileObj) {
|
||
infoDiv.innerHTML = `
|
||
<div style="margin-bottom: 5px;"><strong>📄 ${currentFileObj.name}</strong></div>
|
||
<div style="display: flex; justify-content: space-between;">
|
||
<span>大小: ${currentFileObj.size_kb} KB</span>
|
||
<span>修改: ${currentFileObj.modified_readable.split(' ')[0]}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
async function showFileSelector() {
|
||
const modal = document.getElementById('fileModal');
|
||
const container = document.getElementById('fileListContainer');
|
||
|
||
modal.style.display = 'block';
|
||
container.innerHTML = `
|
||
<div style="text-align: center; padding: 20px;">
|
||
<div class="loading-spinner"></div>
|
||
<p>加载文件列表...</p>
|
||
</div>
|
||
`;
|
||
|
||
const files = await loadFileList();
|
||
|
||
if (files.length === 0) {
|
||
container.innerHTML = `
|
||
<div style="text-align: center; padding: 40px; color: #999;">
|
||
<p style="font-size: 48px; margin-bottom: 10px;">📭</p>
|
||
<p>未找到任何数据文件</p>
|
||
<p style="font-size: 12px; margin-top: 10px;">请先运行Bot生成记忆数据</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="file-list">';
|
||
files.forEach(file => {
|
||
const currentBadge = file.is_current ? '<span class="file-item-current">当前</span>' : '';
|
||
html += `
|
||
<div class="file-item ${file.is_current ? 'current' : ''}" onclick="selectFile('${file.path.replace(/\\/g, '\\\\')}')">
|
||
<div class="file-item-name">📄 ${file.name}${currentBadge}</div>
|
||
<div class="file-item-info">
|
||
<span>大小: ${file.size_kb} KB</span>
|
||
<span>${file.modified_readable}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
html += '</div>';
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function closeFileSelector() {
|
||
document.getElementById('fileModal').style.display = 'none';
|
||
}
|
||
|
||
async function selectFile(filePath) {
|
||
try {
|
||
document.getElementById('loading').style.display = 'block';
|
||
closeFileSelector();
|
||
|
||
const response = await fetch('/visualizer/api/select_file', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ file_path: filePath })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 重新加载图形
|
||
await loadGraph();
|
||
await loadFileList();
|
||
alert('✅ ' + result.message);
|
||
} else {
|
||
alert('❌ 切换文件失败: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('切换文件失败:', error);
|
||
alert('❌ 切换文件失败: ' + error.message);
|
||
} finally {
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 点击模态框外部关闭
|
||
window.onclick = function(event) {
|
||
const modal = document.getElementById('fileModal');
|
||
if (event.target == modal) {
|
||
closeFileSelector();
|
||
}
|
||
}
|
||
|
||
// 页面加载完成后初始化
|
||
window.addEventListener('load', function() {
|
||
initNetwork();
|
||
loadGraph();
|
||
loadFileList();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|