- 在 `KnowledgeGraphSection.vue` 中,删除不准确的节点和边统计信息显示。 - 在 `KnowledgeGraphViewer.vue` 中,更新统计信息逻辑,移除对总节点和总边的依赖。 - 在 `database.js` 和 `graphStore.js` 中,移除图统计信息的初始化和重置逻辑,简化状态管理。
1488 lines
41 KiB
Vue
1488 lines
41 KiB
Vue
<template>
|
||
<div class="knowledge-graph-viewer">
|
||
<!-- 控制面板 -->
|
||
<div class="control-panel" v-if="!props.hideControls">
|
||
<div class="database-section">
|
||
<a-select
|
||
v-if="!hideDbSelector"
|
||
v-model:value="selectedDatabase"
|
||
placeholder="选择数据库"
|
||
size="small"
|
||
style="width: 160px; margin-right: 6px"
|
||
:loading="loadingDatabases"
|
||
@change="onDatabaseChange"
|
||
>
|
||
<a-select-option
|
||
v-for="db in availableDatabases"
|
||
:key="db.db_id"
|
||
:value="db.db_id"
|
||
>
|
||
{{ db.name }} ({{ db.row_count || 0 }} 文件)
|
||
</a-select-option>
|
||
</a-select>
|
||
|
||
<a-select
|
||
v-model:value="selectedLabel"
|
||
placeholder="选择标签/实体类型"
|
||
size="small"
|
||
style="width: 140px"
|
||
:loading="loadingLabels"
|
||
:disabled="!selectedDatabase"
|
||
allow-clear
|
||
show-search
|
||
>
|
||
<a-select-option value="*">所有节点</a-select-option>
|
||
<a-select-option
|
||
v-for="label in availableLabels"
|
||
:key="label"
|
||
:value="label"
|
||
>
|
||
{{ label }}
|
||
</a-select-option>
|
||
</a-select>
|
||
</div>
|
||
|
||
<div class="search-section">
|
||
<a-input-number
|
||
v-model:value="searchParams.max_nodes"
|
||
:min="10"
|
||
:max="1000"
|
||
:step="10"
|
||
size="small"
|
||
addon-before="limit"
|
||
style="width: 140px"
|
||
/>
|
||
<a-input-number
|
||
v-model:value="searchParams.max_depth"
|
||
:min="1"
|
||
:max="5"
|
||
:step="1"
|
||
size="small"
|
||
addon-before="depth"
|
||
style="width: 120px; margin-left: 6px"
|
||
/>
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
:loading="loading"
|
||
@click="loadGraphData"
|
||
:disabled="!selectedDatabase"
|
||
style="margin-left: 6px"
|
||
>
|
||
<SearchOutlined v-if="!loading" /> 加载图谱
|
||
</a-button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Sigma.js图可视化容器 -->
|
||
<div
|
||
class="sigma-container"
|
||
ref="sigmaContainer"
|
||
:class="{ 'loading': loading }"
|
||
></div>
|
||
|
||
<div v-if="!props.hideStats" class="graph-overlay-stats">
|
||
<a-tag color="blue" size="small">节点: {{ stats.displayed_nodes || 0 }}</a-tag>
|
||
<a-tag color="green" size="small">边: {{ stats.displayed_edges || 0 }}</a-tag>
|
||
</div>
|
||
|
||
<!-- 节点详情面板 -->
|
||
<div
|
||
v-if="selectedNodeData"
|
||
class="detail-panel node-panel"
|
||
:style="{ transform: `translate(${nodePanelPosition.x}px, ${nodePanelPosition.y}px)` }"
|
||
@mousedown="startDragPanel('node', $event)"
|
||
>
|
||
<div class="detail-header">
|
||
<div class="panel-type-indicator node-indicator"></div>
|
||
<h4>节点: {{ getNodeDisplayName(selectedNodeData) }}</h4>
|
||
<a-button type="text" size="small" @click="clearSelection">
|
||
<CloseOutlined />
|
||
</a-button>
|
||
</div>
|
||
<div class="detail-content">
|
||
<div class="detail-item">
|
||
<span class="detail-label">ID:</span>
|
||
<span class="detail-value">{{ selectedNodeData.id }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">标签:</span>
|
||
<span class="detail-value">{{ selectedNodeData.labels?.join(', ') || 'N/A' }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">类型:</span>
|
||
<span class="detail-value">{{ selectedNodeData.entity_type }}</span>
|
||
</div>
|
||
<div v-if="selectedNodeData.properties?.description" class="detail-item">
|
||
<span class="detail-label">描述:</span>
|
||
<span class="detail-value">{{ selectedNodeData.properties.description }}</span>
|
||
</div>
|
||
<div class="detail-actions">
|
||
<a-button
|
||
type="primary"
|
||
size="small"
|
||
@click="expandNode(selectedNodeData.id)"
|
||
:loading="expanding"
|
||
:disabled="!selectedDatabase"
|
||
>
|
||
展开相邻节点
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 边详情面板 -->
|
||
<div
|
||
v-if="selectedEdgeData"
|
||
class="detail-panel edge-panel"
|
||
:style="{ transform: `translate(${intelligentEdgePanelPosition.x}px, ${intelligentEdgePanelPosition.y}px)` }"
|
||
@mousedown="startDragPanel('edge', $event)"
|
||
>
|
||
<div class="detail-header">
|
||
<div class="panel-type-indicator edge-indicator"></div>
|
||
<h4>边: {{ getEdgeDisplayName(selectedEdgeData) }}</h4>
|
||
<a-button type="text" size="small" @click="clearSelection">
|
||
<CloseOutlined />
|
||
</a-button>
|
||
</div>
|
||
<div class="detail-content">
|
||
<div class="detail-item">
|
||
<span class="detail-label">ID:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.id }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">源节点:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.source }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">目标节点:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.target }}</span>
|
||
</div>
|
||
<div class="detail-item">
|
||
<span class="detail-label">类型:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.type || 'DIRECTED' }}</span>
|
||
</div>
|
||
<div v-if="selectedEdgeData.properties?.weight" class="detail-item">
|
||
<span class="detail-label">权重:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.properties.weight }}</span>
|
||
</div>
|
||
<div v-if="selectedEdgeData.properties?.keywords" class="detail-item">
|
||
<span class="detail-label">关键词:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.properties.keywords }}</span>
|
||
</div>
|
||
<div v-if="selectedEdgeData.properties?.description" class="detail-item">
|
||
<span class="detail-label">描述:</span>
|
||
<span class="detail-value">{{ selectedEdgeData.properties.description }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- <div class="legend" v-if="entityTypes.length > 0">
|
||
<div class="legend-header">
|
||
<h4>实体类型</h4>
|
||
</div>
|
||
<div class="legend-content">
|
||
<div class="legend-item" v-for="type in entityTypes" :key="type.type">
|
||
<div
|
||
class="legend-color"
|
||
:style="{ backgroundColor: getEntityColor(type.type) }"
|
||
></div>
|
||
<span>{{ type.type }} ({{ type.count }})</span>
|
||
</div>
|
||
</div>
|
||
</div> -->
|
||
|
||
<!-- 控制按钮 (简化版) -->
|
||
<div class="graph-controls">
|
||
<div class="control-group">
|
||
<a-button @click="zoomIn" size="small" type="text" class="control-btn">
|
||
<PlusOutlined />
|
||
</a-button>
|
||
<a-button @click="zoomOut" size="small" type="text" class="control-btn">
|
||
<MinusOutlined />
|
||
</a-button>
|
||
<a-button @click="resetCamera" size="small" type="text" class="control-btn" title="重置视图">
|
||
<HomeOutlined />
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 加载遮罩 -->
|
||
<div v-if="loading" class="loading-overlay">
|
||
<div class="loading-content">
|
||
<a-spin size="large" />
|
||
<p>{{ loadingMessage }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import {
|
||
SearchOutlined,
|
||
ClearOutlined,
|
||
CloseOutlined,
|
||
PlusOutlined,
|
||
MinusOutlined,
|
||
HomeOutlined
|
||
} from '@ant-design/icons-vue'
|
||
import Sigma from 'sigma'
|
||
import { NodeBorderProgram } from '@sigma/node-border'
|
||
import EdgeCurveProgram, { EdgeCurvedArrowProgram } from '@sigma/edge-curve'
|
||
import { EdgeArrowProgram } from 'sigma/rendering'
|
||
|
||
import { lightragApi } from '@/apis/graph_api'
|
||
import { useGraphStore } from '@/stores/graphStore'
|
||
import { useDatabaseStore } from '@/stores/database'
|
||
import '@/assets/css/sigma.css'
|
||
|
||
// 定义 props
|
||
const props = defineProps({
|
||
initialDatabaseId: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
hideDbSelector: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
hideStats: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
hideControls: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
initialLimit: {
|
||
type: Number,
|
||
default: 200
|
||
},
|
||
initialDepth: {
|
||
type: Number,
|
||
default: 2
|
||
}
|
||
})
|
||
|
||
// 定义 emits
|
||
const emit = defineEmits(['update:stats', 'refresh-graph', 'clear-graph'])
|
||
|
||
// 状态管理
|
||
const graphStore = useGraphStore()
|
||
const databaseStore = useDatabaseStore()
|
||
|
||
// 响应式引用
|
||
const loading = ref(false)
|
||
const loadingDatabases = ref(false)
|
||
const loadingLabels = ref(false)
|
||
const expanding = ref(false)
|
||
const sigmaContainer = ref(null)
|
||
const layoutRunning = ref(false)
|
||
const loadingMessage = ref('加载图数据中...')
|
||
|
||
// 面板拖拽相关
|
||
const nodePanelPosition = ref({ x: 12, y: 60 })
|
||
const edgePanelPosition = ref({ x: 12, y: 150 }) // 降低初始Y位置以适应紧凑模式
|
||
const dragging = ref({ active: false, type: '', startX: 0, startY: 0, initialX: 0, initialY: 0 })
|
||
|
||
// 数据库相关状态
|
||
const selectedDatabase = ref('')
|
||
const availableDatabases = ref([])
|
||
const selectedLabel = ref('*')
|
||
const availableLabels = ref([])
|
||
|
||
// Sigma实例
|
||
let sigmaInstance = null
|
||
let layoutWorker = null
|
||
|
||
// 搜索和筛选参数
|
||
const searchParams = reactive({
|
||
max_nodes: props.initialLimit,
|
||
max_depth: props.initialDepth
|
||
})
|
||
|
||
// 计算属性
|
||
const entityTypes = computed(() => graphStore.entityTypes)
|
||
const stats = computed(() => graphStore.stats)
|
||
const selectedNodeData = computed(() => graphStore.selectedNodeData)
|
||
const selectedEdgeData = computed(() => graphStore.selectedEdgeData)
|
||
|
||
// 智能边面板位置 - 确保在紧凑模式下也能正确显示
|
||
const intelligentEdgePanelPosition = computed(() => {
|
||
if (!sigmaContainer.value) return edgePanelPosition.value
|
||
|
||
const containerHeight = sigmaContainer.value.clientHeight
|
||
const panelHeight = 200 // 估计的面板高度
|
||
const nodePanelBottom = selectedNodeData.value ? nodePanelPosition.value.y + 200 : 0
|
||
|
||
// 如果有节点面板显示,将边面板放在节点面板下方
|
||
if (selectedNodeData.value) {
|
||
return {
|
||
x: edgePanelPosition.value.x + 260, // 在节点面板右侧
|
||
y: Math.min(nodePanelPosition.value.y, containerHeight - panelHeight - 20)
|
||
}
|
||
}
|
||
|
||
// 确保边面板在容器范围内
|
||
return {
|
||
x: edgePanelPosition.value.x,
|
||
y: Math.min(edgePanelPosition.value.y, Math.max(60, containerHeight - panelHeight - 20))
|
||
}
|
||
})
|
||
|
||
// 获取节点显示名称
|
||
const getNodeDisplayName = (node) => {
|
||
if (node.properties?.entity_id) {
|
||
return node.properties.entity_id
|
||
}
|
||
if (node.labels && node.labels.length > 0) {
|
||
return node.labels[0]
|
||
}
|
||
return node.id || 'Unknown'
|
||
}
|
||
|
||
// 获取边显示名称
|
||
const getEdgeDisplayName = (edge) => {
|
||
if (edge.properties?.keywords) {
|
||
return edge.properties.keywords
|
||
}
|
||
if (edge.properties?.description) {
|
||
return edge.properties.description.substring(0, 20) + '...'
|
||
}
|
||
return `${edge.source} -> ${edge.target}`
|
||
}
|
||
|
||
// 加载可用数据库
|
||
const loadAvailableDatabases = async () => {
|
||
loadingDatabases.value = true
|
||
try {
|
||
const response = await lightragApi.getDatabases()
|
||
if (response.success) {
|
||
availableDatabases.value = response.data.databases || []
|
||
|
||
// 如果有初始数据库 ID,直接使用它(上层已经检查过类型)
|
||
if (props.initialDatabaseId) {
|
||
selectedDatabase.value = props.initialDatabaseId
|
||
await onDatabaseChange(selectedDatabase.value)
|
||
} else if (availableDatabases.value.length > 0 && !selectedDatabase.value) {
|
||
// 没有初始数据库ID,选择第一个可用的数据库
|
||
selectedDatabase.value = availableDatabases.value[0].db_id
|
||
await onDatabaseChange(selectedDatabase.value)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载数据库列表失败:', error)
|
||
message.error('加载数据库列表失败: ' + error.message)
|
||
} finally {
|
||
loadingDatabases.value = false
|
||
}
|
||
}
|
||
|
||
// 加载图标签
|
||
const loadGraphLabels = async (dbId) => {
|
||
if (!dbId) return
|
||
|
||
loadingLabels.value = true
|
||
try {
|
||
const response = await lightragApi.getLabels(dbId)
|
||
if (response.success) {
|
||
availableLabels.value = response.data.labels || []
|
||
}
|
||
} catch (error) {
|
||
console.error('加载图标签失败:', error)
|
||
availableLabels.value = []
|
||
} finally {
|
||
loadingLabels.value = false
|
||
}
|
||
}
|
||
|
||
// 数据库切换处理
|
||
const onDatabaseChange = async (dbId) => {
|
||
if (!dbId) return
|
||
|
||
selectedDatabase.value = dbId
|
||
selectedLabel.value = '*'
|
||
|
||
// 清空当前图谱
|
||
clearGraph()
|
||
|
||
// 加载新数据库的标签
|
||
await loadGraphLabels(dbId)
|
||
|
||
// message.info(`已切换到数据库: ${availableDatabases.value.find(db => db.db_id === dbId)?.name || dbId}`)
|
||
}
|
||
|
||
// Sigma.js配置
|
||
const sigmaSettings = {
|
||
allowInvalidContainer: true,
|
||
defaultNodeType: 'default',
|
||
defaultEdgeType: 'curvedArrow',
|
||
renderEdgeLabels: true,
|
||
renderLabels: true,
|
||
enableEdgeEvents: true,
|
||
// 添加 passive event listener 配置以解决警告
|
||
enableCameraAPI: true,
|
||
touchActions: 'pan-y pinch-zoom',
|
||
eventBubbling: false,
|
||
edgeProgramClasses: {
|
||
arrow: EdgeArrowProgram,
|
||
curvedArrow: EdgeCurvedArrowProgram,
|
||
curved: EdgeCurveProgram
|
||
},
|
||
nodeProgramClasses: {
|
||
default: NodeBorderProgram
|
||
},
|
||
labelSize: 12,
|
||
edgeLabelSize: 10,
|
||
labelColor: {
|
||
color: '#000'
|
||
},
|
||
edgeLabelColor: {
|
||
color: '#666'
|
||
},
|
||
minEdgeThickness: 4, // 增加最小边厚度,提高点击性
|
||
maxEdgeThickness: 10, // 增加最大边厚度
|
||
// 添加边点击相关配置
|
||
edgeClickTolerance: 15, // 增加边点击容差,增加点击检测区域
|
||
edgeHoverTolerance: 12, // 增加边悬停容差
|
||
zoomToSizeRatioFunction: (x) => x, // 确保缩放时边的厚度保持合理
|
||
eventListenerOptions: {
|
||
wheel: { passive: true },
|
||
touchstart: { passive: true },
|
||
},
|
||
}
|
||
|
||
// 初始化Sigma.js
|
||
const initSigma = async () => {
|
||
if (!sigmaContainer.value || sigmaInstance) return
|
||
|
||
try {
|
||
sigmaInstance = new Sigma(
|
||
graphStore.sigmaGraph || graphStore.createSigmaGraph({ nodes: [], edges: [] }),
|
||
sigmaContainer.value,
|
||
sigmaSettings
|
||
)
|
||
|
||
// 保存实例到状态管理
|
||
graphStore.setSigmaInstance(sigmaInstance)
|
||
|
||
// 注册事件监听器
|
||
registerEvents()
|
||
|
||
console.log('Sigma.js 实例创建成功')
|
||
} catch (error) {
|
||
console.error('初始化Sigma.js失败:', error)
|
||
message.error('图可视化初始化失败')
|
||
}
|
||
}
|
||
|
||
// 拖拽状态
|
||
const isDragging = ref(false)
|
||
let draggedNode = null
|
||
|
||
// 注册事件监听器
|
||
const registerEvents = () => {
|
||
if (!sigmaInstance) return
|
||
|
||
// 节点点击事件
|
||
sigmaInstance.on('clickNode', ({ node }) => {
|
||
try {
|
||
const graph = sigmaInstance.getGraph()
|
||
if (graph.hasNode(node)) {
|
||
console.log('Clicked node:', node)
|
||
|
||
// 立即设置选中节点,显示详情面板
|
||
graphStore.setSelectedNode(node, false) // 先不移动相机
|
||
|
||
// 获取节点数据并确保设置成功
|
||
const nodeData = graph.getNodeAttributes(node)
|
||
console.log('Node data:', nodeData)
|
||
|
||
// 延迟一点后再移动相机,确保详情面板已显示
|
||
setTimeout(() => {
|
||
if (graphStore.selectedNode === node) {
|
||
graphStore.moveToSelectedNode = true
|
||
graphStore.setSelectedNode(node, true) // 现在移动相机
|
||
}
|
||
}, 100)
|
||
|
||
} else {
|
||
console.warn('Clicked node does not exist in graph:', node)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error handling node click:', error)
|
||
}
|
||
})
|
||
|
||
// 节点悬停事件
|
||
sigmaInstance.on('enterNode', ({ node }) => {
|
||
if (!isDragging.value) {
|
||
try {
|
||
const graph = sigmaInstance.getGraph()
|
||
if (graph.hasNode(node)) {
|
||
graphStore.setFocusedNode(node)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error handling node enter:', error)
|
||
}
|
||
}
|
||
})
|
||
|
||
sigmaInstance.on('leaveNode', () => {
|
||
if (!isDragging.value) {
|
||
graphStore.setFocusedNode(null)
|
||
}
|
||
})
|
||
|
||
// 边点击事件
|
||
sigmaInstance.on('clickEdge', ({ edge }) => {
|
||
try {
|
||
console.log('边被点击 - Sigma边ID:', edge)
|
||
const graph = sigmaInstance.getGraph()
|
||
if (graph.hasEdge(edge)) {
|
||
// 获取Sigma边的属性,其中包含原始数据
|
||
const sigmaEdgeData = graph.getEdgeAttributes(edge)
|
||
console.log('Sigma边属性:', sigmaEdgeData)
|
||
|
||
// 立即设置选中的边 - 使用Sigma边ID
|
||
graphStore.setSelectedEdge(edge)
|
||
|
||
// 确保边面板显示
|
||
nextTick(() => {
|
||
if (selectedEdgeData.value) {
|
||
console.log('边面板应该显示:', selectedEdgeData.value)
|
||
message.success(`已选中边: ${getEdgeDisplayName(selectedEdgeData.value)}`)
|
||
} else {
|
||
console.warn('selectedEdgeData为空,尝试使用Sigma边数据')
|
||
// 如果无法从rawGraph中找到,直接使用Sigma边的原始数据
|
||
const fallbackData = sigmaEdgeData.originalData || {
|
||
id: edge,
|
||
source: graph.source(edge),
|
||
target: graph.target(edge),
|
||
properties: {
|
||
keywords: sigmaEdgeData.label || '',
|
||
description: sigmaEdgeData.label || '',
|
||
weight: sigmaEdgeData.originalWeight || 1
|
||
}
|
||
}
|
||
console.log('使用回退边数据:', fallbackData)
|
||
message.success(`已选中边: ${getEdgeDisplayName(fallbackData)}`)
|
||
}
|
||
})
|
||
} else {
|
||
console.warn('点击的边不存在于图中:', edge)
|
||
message.warning('点击的边不存在于图中')
|
||
}
|
||
} catch (error) {
|
||
console.error('处理边点击事件时出错:', error)
|
||
message.error('处理边点击事件时出错')
|
||
}
|
||
})
|
||
|
||
// 添加边悬停事件以提高交互体验
|
||
sigmaInstance.on('enterEdge', ({ edge }) => {
|
||
if (!isDragging.value) {
|
||
try {
|
||
const graph = sigmaInstance.getGraph()
|
||
if (graph.hasEdge(edge)) {
|
||
// 高亮悬停的边
|
||
graph.setEdgeAttribute(edge, 'color', '#ff0000')
|
||
graph.setEdgeAttribute(edge, 'size', (graph.getEdgeAttribute(edge, 'size') || 1) * 1.5)
|
||
sigmaInstance.refresh()
|
||
console.log('悬停在边上:', edge)
|
||
}
|
||
} catch (error) {
|
||
console.error('处理边悬停事件时出错:', error)
|
||
}
|
||
}
|
||
})
|
||
|
||
sigmaInstance.on('leaveEdge', ({ edge }) => {
|
||
if (!isDragging.value) {
|
||
try {
|
||
const graph = sigmaInstance.getGraph()
|
||
if (graph.hasEdge(edge)) {
|
||
// 恢复边的原始样式
|
||
const originalData = graph.getEdgeAttribute(edge, 'originalData')
|
||
graph.setEdgeAttribute(edge, 'color', originalData?.color || '#666')
|
||
graph.setEdgeAttribute(edge, 'size', originalData?.size || 1)
|
||
sigmaInstance.refresh()
|
||
}
|
||
} catch (error) {
|
||
console.error('处理边离开事件时出错:', error)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 画布点击事件
|
||
sigmaInstance.on('clickStage', () => {
|
||
graphStore.clearSelection()
|
||
})
|
||
|
||
// 拖拽支持
|
||
sigmaInstance.on('downNode', (e) => {
|
||
isDragging.value = true
|
||
draggedNode = e.node
|
||
|
||
// 高亮拖拽节点
|
||
const graph = sigmaInstance.getGraph()
|
||
graph.setNodeAttribute(draggedNode, 'highlighted', true)
|
||
|
||
// 停止布局
|
||
if (layoutWorker) {
|
||
layoutWorker.stop()
|
||
}
|
||
})
|
||
|
||
sigmaInstance.getMouseCaptor().on('mousemovebody', (e) => {
|
||
if (!isDragging.value || !draggedNode) return
|
||
|
||
// 获取新位置
|
||
const pos = sigmaInstance.viewportToGraph(e)
|
||
|
||
// 更新节点位置
|
||
const graph = sigmaInstance.getGraph()
|
||
graph.setNodeAttribute(draggedNode, 'x', pos.x)
|
||
graph.setNodeAttribute(draggedNode, 'y', pos.y)
|
||
|
||
// 阻止默认行为
|
||
e.preventSigmaDefault()
|
||
e.original.preventDefault()
|
||
e.original.stopPropagation()
|
||
})
|
||
|
||
sigmaInstance.getMouseCaptor().on('mouseup', () => {
|
||
if (draggedNode) {
|
||
const graph = sigmaInstance.getGraph()
|
||
graph.removeNodeAttribute(draggedNode, 'highlighted')
|
||
}
|
||
isDragging.value = false
|
||
draggedNode = null
|
||
})
|
||
}
|
||
|
||
// 加载图数据
|
||
const loadGraphData = async () => {
|
||
if (!selectedDatabase.value) {
|
||
console.warn('尝试加载图数据但没有选中数据库')
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
loadingMessage.value = '加载图数据中...'
|
||
graphStore.setIsFetching(true)
|
||
|
||
try {
|
||
const [graphResponse, statsResponse] = await Promise.all([
|
||
lightragApi.getSubgraph({
|
||
db_id: selectedDatabase.value,
|
||
node_label: selectedLabel.value || '*',
|
||
max_depth: searchParams.max_depth,
|
||
max_nodes: searchParams.max_nodes
|
||
}),
|
||
lightragApi.getStats(selectedDatabase.value)
|
||
])
|
||
|
||
if (graphResponse.success && statsResponse.success) {
|
||
// 设置实体类型
|
||
graphStore.setEntityTypes(statsResponse.data.entity_types || [])
|
||
|
||
// 创建图数据
|
||
const rawGraph = graphStore.createGraphFromApiData(
|
||
graphResponse.data.nodes,
|
||
graphResponse.data.edges
|
||
)
|
||
|
||
// 设置图数据
|
||
graphStore.setRawGraph(rawGraph)
|
||
// 更新当前显示的节点和边数量
|
||
graphStore.stats = {
|
||
displayed_nodes: graphResponse.data.nodes.length,
|
||
displayed_edges: graphResponse.data.edges.length,
|
||
is_truncated: graphResponse.data.is_truncated
|
||
}
|
||
|
||
console.log('Raw graph created:', {
|
||
nodes: rawGraph.nodes.length,
|
||
edges: rawGraph.edges.length,
|
||
sampleNode: rawGraph.nodes[0],
|
||
sampleEdge: rawGraph.edges[0]
|
||
})
|
||
|
||
// 创建Sigma图
|
||
const sigmaGraph = graphStore.createSigmaGraph(rawGraph)
|
||
graphStore.setSigmaGraph(sigmaGraph)
|
||
|
||
// 应用布局
|
||
await applyLayout(sigmaGraph)
|
||
|
||
// 更新Sigma实例
|
||
if (sigmaInstance) {
|
||
sigmaInstance.setGraph(sigmaGraph)
|
||
sigmaInstance.refresh()
|
||
} else {
|
||
await nextTick()
|
||
await initSigma()
|
||
}
|
||
|
||
// message.success(`加载成功:${rawGraph.nodes.length} 个节点,${rawGraph.edges.length} 条边${graphResponse.data.is_truncated ? ' (已截断)' : ''}`)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载图数据失败:', error)
|
||
message.error('加载图数据失败: ' + error.message)
|
||
} finally {
|
||
loading.value = false
|
||
loadingMessage.value = '加载图数据中...'
|
||
graphStore.setIsFetching(false)
|
||
}
|
||
}
|
||
|
||
// 加载完整图数据
|
||
const loadFullGraph = async () => {
|
||
console.log('loadFullGraph 调用:', {
|
||
selectedDatabase: selectedDatabase.value,
|
||
availableDatabases: availableDatabases.value.length,
|
||
initialDatabaseId: props.initialDatabaseId
|
||
});
|
||
|
||
// 如果还没有选中数据库,等待数据库加载完成
|
||
if (!selectedDatabase.value && props.initialDatabaseId) {
|
||
console.log('等待数据库加载完成...');
|
||
// 等待一小段时间让异步操作完成
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
}
|
||
|
||
// 再次检查是否有选中的数据库
|
||
if (!selectedDatabase.value) {
|
||
console.warn('loadFullGraph: 没有选中的数据库,无法加载图谱')
|
||
return
|
||
}
|
||
|
||
selectedLabel.value = '*'
|
||
await loadGraphData()
|
||
emit('refresh-graph')
|
||
}
|
||
|
||
// 清空图谱
|
||
const clearGraph = () => {
|
||
if (sigmaInstance) {
|
||
const emptyGraph = graphStore.createSigmaGraph({ nodes: [], edges: [] })
|
||
sigmaInstance.setGraph(emptyGraph)
|
||
sigmaInstance.refresh()
|
||
}
|
||
graphStore.reset()
|
||
emit('clear-graph')
|
||
}
|
||
|
||
// 应用布局算法
|
||
const applyLayout = async (graph) => {
|
||
return new Promise((resolve) => {
|
||
if (!graph || graph.order === 0) {
|
||
resolve()
|
||
return
|
||
}
|
||
|
||
loadingMessage.value = '计算布局中...'
|
||
|
||
// 简单的力导向布局
|
||
const nodes = graph.nodes()
|
||
const edges = graph.edges()
|
||
|
||
// 随机初始位置
|
||
nodes.forEach((node) => {
|
||
if (!graph.hasNodeAttribute(node, 'x')) {
|
||
graph.setNodeAttribute(node, 'x', Math.random() * 1000)
|
||
}
|
||
if (!graph.hasNodeAttribute(node, 'y')) {
|
||
graph.setNodeAttribute(node, 'y', Math.random() * 1000)
|
||
}
|
||
})
|
||
|
||
// 简单的力导向迭代
|
||
const iterations = 100
|
||
for (let i = 0; i < iterations; i++) {
|
||
// 斥力
|
||
for (let j = 0; j < nodes.length; j++) {
|
||
for (let k = j + 1; k < nodes.length; k++) {
|
||
const node1 = nodes[j]
|
||
const node2 = nodes[k]
|
||
|
||
const x1 = graph.getNodeAttribute(node1, 'x')
|
||
const y1 = graph.getNodeAttribute(node1, 'y')
|
||
const x2 = graph.getNodeAttribute(node2, 'x')
|
||
const y2 = graph.getNodeAttribute(node2, 'y')
|
||
|
||
const dx = x2 - x1
|
||
const dy = y2 - y1
|
||
const distance = Math.sqrt(dx * dx + dy * dy) || 1
|
||
|
||
const force = 1000 / (distance * distance)
|
||
const fx = (dx / distance) * force
|
||
const fy = (dy / distance) * force
|
||
|
||
graph.setNodeAttribute(node1, 'x', x1 - fx)
|
||
graph.setNodeAttribute(node1, 'y', y1 - fy)
|
||
graph.setNodeAttribute(node2, 'x', x2 + fx)
|
||
graph.setNodeAttribute(node2, 'y', y2 + fy)
|
||
}
|
||
}
|
||
|
||
// 引力
|
||
edges.forEach((edge) => {
|
||
const source = graph.source(edge)
|
||
const target = graph.target(edge)
|
||
|
||
const x1 = graph.getNodeAttribute(source, 'x')
|
||
const y1 = graph.getNodeAttribute(source, 'y')
|
||
const x2 = graph.getNodeAttribute(target, 'x')
|
||
const y2 = graph.getNodeAttribute(target, 'y')
|
||
|
||
const dx = x2 - x1
|
||
const dy = y2 - y1
|
||
const distance = Math.sqrt(dx * dx + dy * dy) || 1
|
||
|
||
const force = distance * 0.01
|
||
const fx = (dx / distance) * force
|
||
const fy = (dy / distance) * force
|
||
|
||
graph.setNodeAttribute(source, 'x', x1 + fx)
|
||
graph.setNodeAttribute(source, 'y', y1 + fy)
|
||
graph.setNodeAttribute(target, 'x', x2 - fx)
|
||
graph.setNodeAttribute(target, 'y', y2 - fy)
|
||
})
|
||
}
|
||
|
||
resolve()
|
||
})
|
||
}
|
||
|
||
// 展开节点
|
||
const expandNode = async (nodeId) => {
|
||
if (!selectedDatabase.value) {
|
||
console.warn('尝试展开节点但没有选中数据库')
|
||
return
|
||
}
|
||
|
||
expanding.value = true
|
||
try {
|
||
const response = await lightragApi.getSubgraph({
|
||
db_id: selectedDatabase.value,
|
||
node_label: nodeId,
|
||
max_depth: 1,
|
||
max_nodes: 50
|
||
})
|
||
|
||
if (response.success) {
|
||
// 合并新数据的逻辑
|
||
const existingNodeIds = new Set(graphStore.rawGraph.nodes.map(n => n.id))
|
||
const existingEdgeIds = new Set(graphStore.rawGraph.edges.map(e => e.id))
|
||
|
||
const newNodes = response.data.nodes.filter(node => !existingNodeIds.has(node.id))
|
||
const newEdges = response.data.edges.filter(edge => !existingEdgeIds.has(edge.id))
|
||
|
||
if (newNodes.length > 0 || newEdges.length > 0) {
|
||
// 重新创建图数据
|
||
const allNodes = [...graphStore.rawGraph.nodes, ...newNodes]
|
||
const allEdges = [...graphStore.rawGraph.edges, ...newEdges]
|
||
|
||
const rawGraph = graphStore.createGraphFromApiData(allNodes, allEdges)
|
||
graphStore.setRawGraph(rawGraph)
|
||
|
||
const sigmaGraph = graphStore.createSigmaGraph(rawGraph)
|
||
graphStore.setSigmaGraph(sigmaGraph)
|
||
|
||
if (sigmaInstance) {
|
||
sigmaInstance.setGraph(sigmaGraph)
|
||
sigmaInstance.refresh()
|
||
}
|
||
|
||
message.success(`展开成功:新增 ${newNodes.length} 个节点,${newEdges.length} 条边`)
|
||
} else {
|
||
message.info('没有新的相邻节点')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('展开节点失败:', error)
|
||
message.error('展开节点失败: ' + error.message)
|
||
} finally {
|
||
expanding.value = false
|
||
}
|
||
}
|
||
|
||
// 控制方法
|
||
const zoomIn = () => {
|
||
if (sigmaInstance) {
|
||
const camera = sigmaInstance.getCamera()
|
||
camera.animatedZoom({ duration: 200 })
|
||
}
|
||
}
|
||
|
||
const zoomOut = () => {
|
||
if (sigmaInstance) {
|
||
const camera = sigmaInstance.getCamera()
|
||
camera.animatedUnzoom({ duration: 200 })
|
||
}
|
||
}
|
||
|
||
const resetCamera = () => {
|
||
if (sigmaInstance) {
|
||
try {
|
||
const camera = sigmaInstance.getCamera()
|
||
const graph = sigmaInstance.getGraph()
|
||
|
||
// 如果图为空,直接重置
|
||
if (graph.order === 0) {
|
||
camera.animatedReset({ duration: 500 })
|
||
return
|
||
}
|
||
|
||
// 计算图的边界以确保所有节点都可见
|
||
const nodes = graph.nodes()
|
||
if (nodes.length > 0) {
|
||
const bounds = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
|
||
|
||
nodes.forEach(node => {
|
||
const attrs = graph.getNodeAttributes(node)
|
||
if (typeof attrs.x === 'number' && typeof attrs.y === 'number') {
|
||
bounds.minX = Math.min(bounds.minX, attrs.x)
|
||
bounds.maxX = Math.max(bounds.maxX, attrs.x)
|
||
bounds.minY = Math.min(bounds.minY, attrs.y)
|
||
bounds.maxY = Math.max(bounds.maxY, attrs.y)
|
||
}
|
||
})
|
||
|
||
// 只有在边界有效时才使用fitBounds
|
||
if (isFinite(bounds.minX) && isFinite(bounds.maxX) && isFinite(bounds.minY) && isFinite(bounds.maxY)) {
|
||
const centerX = (bounds.minX + bounds.maxX) / 2
|
||
const centerY = (bounds.minY + bounds.maxY) / 2
|
||
const padding = 50
|
||
|
||
camera.animate({
|
||
x: centerX,
|
||
y: centerY,
|
||
ratio: Math.max(
|
||
(bounds.maxX - bounds.minX + padding) / window.innerWidth,
|
||
(bounds.maxY - bounds.minY + padding) / window.innerHeight
|
||
) || 1.0
|
||
}, { duration: 500 })
|
||
} else {
|
||
// 回退到默认重置
|
||
camera.animatedReset({ duration: 500 })
|
||
}
|
||
} else {
|
||
camera.animatedReset({ duration: 500 })
|
||
}
|
||
|
||
console.log('Camera reset completed')
|
||
message.success('视图已重置')
|
||
} catch (error) {
|
||
console.error('Error resetting camera:', error)
|
||
// 强制重置
|
||
if (sigmaInstance) {
|
||
const camera = sigmaInstance.getCamera()
|
||
camera.setState({ x: 0, y: 0, ratio: 1.0 })
|
||
sigmaInstance.refresh()
|
||
}
|
||
message.info('视图已强制重置')
|
||
}
|
||
}
|
||
}
|
||
|
||
const toggleLayout = () => {
|
||
// 简单的布局切换
|
||
layoutRunning.value = !layoutRunning.value
|
||
if (layoutRunning.value && sigmaInstance) {
|
||
applyLayout(sigmaInstance.getGraph())
|
||
}
|
||
}
|
||
|
||
const clearSelection = () => {
|
||
graphStore.clearSelection()
|
||
}
|
||
|
||
const getEntityColor = (entityType) => {
|
||
return graphStore.getEntityColor(entityType)
|
||
}
|
||
|
||
// 面板拖拽功能
|
||
const startDragPanel = (type, event) => {
|
||
event.preventDefault()
|
||
|
||
if (!sigmaContainer.value) return
|
||
|
||
const currentPosition = type === 'node' ? nodePanelPosition.value : edgePanelPosition.value
|
||
|
||
// 获取容器位置,计算相对于容器的鼠标位置
|
||
const containerRect = sigmaContainer.value.getBoundingClientRect()
|
||
const relativeX = event.clientX - containerRect.left
|
||
const relativeY = event.clientY - containerRect.top
|
||
|
||
dragging.value = {
|
||
active: true,
|
||
type: type,
|
||
startX: relativeX,
|
||
startY: relativeY,
|
||
initialX: currentPosition.x,
|
||
initialY: currentPosition.y
|
||
}
|
||
|
||
document.addEventListener('mousemove', onDragPanel)
|
||
document.addEventListener('mouseup', stopDragPanel)
|
||
}
|
||
|
||
const onDragPanel = (event) => {
|
||
if (!dragging.value.active || !sigmaContainer.value) return
|
||
|
||
// 获取容器位置和尺寸
|
||
const containerRect = sigmaContainer.value.getBoundingClientRect()
|
||
|
||
// 计算当前鼠标相对于容器的位置
|
||
const currentRelativeX = event.clientX - containerRect.left
|
||
const currentRelativeY = event.clientY - containerRect.top
|
||
|
||
// 计算拖拽偏移
|
||
const deltaX = currentRelativeX - dragging.value.startX
|
||
const deltaY = currentRelativeY - dragging.value.startY
|
||
|
||
const maxX = containerRect.width - 260 // 面板宽度为240px + 一些边距
|
||
const maxY = containerRect.height - 200 // 面板高度约为200px
|
||
|
||
const newPosition = {
|
||
x: Math.max(0, Math.min(maxX, dragging.value.initialX + deltaX)),
|
||
y: Math.max(0, Math.min(maxY, dragging.value.initialY + deltaY))
|
||
}
|
||
|
||
if (dragging.value.type === 'node') {
|
||
nodePanelPosition.value = newPosition
|
||
} else {
|
||
edgePanelPosition.value = newPosition
|
||
}
|
||
}
|
||
|
||
const stopDragPanel = () => {
|
||
dragging.value.active = false
|
||
document.removeEventListener('mousemove', onDragPanel)
|
||
document.removeEventListener('mouseup', stopDragPanel)
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(async () => {
|
||
await nextTick()
|
||
await loadAvailableDatabases()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
// 清理拖拽事件监听器
|
||
document.removeEventListener('mousemove', onDragPanel)
|
||
document.removeEventListener('mouseup', stopDragPanel)
|
||
|
||
if (sigmaInstance) {
|
||
sigmaInstance.kill()
|
||
sigmaInstance = null
|
||
}
|
||
if (layoutWorker) {
|
||
layoutWorker.stop()
|
||
}
|
||
graphStore.reset()
|
||
})
|
||
|
||
// 监听选中节点数据变化,用于调试
|
||
watch(() => graphStore.selectedNodeData, (nodeData) => {
|
||
console.log('Selected node data changed:', nodeData)
|
||
})
|
||
|
||
// 监听选中边数据变化,用于调试和确保面板显示
|
||
watch(() => graphStore.selectedEdgeData, (edgeData) => {
|
||
console.log('Selected edge data changed:', edgeData)
|
||
if (edgeData) {
|
||
console.log('边面板应该显示,当前位置:', intelligentEdgePanelPosition.value)
|
||
}
|
||
})
|
||
|
||
// 监听选中节点变化,移动相机
|
||
watch(() => graphStore.selectedNode, (nodeId) => {
|
||
if (nodeId && graphStore.moveToSelectedNode && sigmaInstance) {
|
||
try {
|
||
const graph = sigmaInstance.getGraph()
|
||
|
||
// 检查节点是否存在
|
||
if (!graph.hasNode(nodeId)) {
|
||
console.warn('Selected node does not exist in graph:', nodeId)
|
||
graphStore.moveToSelectedNode = false
|
||
return
|
||
}
|
||
|
||
const nodeAttributes = graph.getNodeAttributes(nodeId)
|
||
|
||
// 检查节点属性是否有效
|
||
if (!nodeAttributes || typeof nodeAttributes.x !== 'number' || typeof nodeAttributes.y !== 'number') {
|
||
console.warn('Invalid node attributes for node:', nodeId, nodeAttributes)
|
||
graphStore.moveToSelectedNode = false
|
||
return
|
||
}
|
||
|
||
const camera = sigmaInstance.getCamera()
|
||
const currentState = camera.getState()
|
||
|
||
console.log('Moving camera to node:', nodeId, {
|
||
x: nodeAttributes.x,
|
||
y: nodeAttributes.y,
|
||
currentRatio: currentState.ratio
|
||
})
|
||
|
||
// 计算合适的缩放比例,避免过度缩放
|
||
const currentRatio = currentState.ratio || 1.0
|
||
const targetRatio = Math.max(0.1, Math.min(currentRatio * 0.7, 0.6))
|
||
|
||
// 验证节点位置是否有效
|
||
const isValidPosition = (
|
||
typeof nodeAttributes.x === 'number' &&
|
||
typeof nodeAttributes.y === 'number' &&
|
||
!isNaN(nodeAttributes.x) &&
|
||
!isNaN(nodeAttributes.y) &&
|
||
isFinite(nodeAttributes.x) &&
|
||
isFinite(nodeAttributes.y)
|
||
)
|
||
|
||
if (!isValidPosition) {
|
||
console.warn('Invalid node position, skipping camera movement:', nodeAttributes)
|
||
return
|
||
}
|
||
|
||
// 移动相机到节点位置,使用安全的缩放比例
|
||
camera.animate(
|
||
{
|
||
x: nodeAttributes.x,
|
||
y: nodeAttributes.y,
|
||
ratio: targetRatio // 使用计算出的安全缩放比例
|
||
},
|
||
{
|
||
duration: 600 // 适中的动画时间
|
||
}
|
||
)
|
||
} catch (error) {
|
||
console.error('Error moving camera to selected node:', error)
|
||
// 如果相机移动失败,至少确保显示节点详情
|
||
if (nodeId) {
|
||
const graph = sigmaInstance.getGraph()
|
||
if (graph.hasNode(nodeId)) {
|
||
const nodeData = graph.getNodeAttributes(nodeId)
|
||
console.log('Fallback: showing node details without camera movement', nodeData)
|
||
}
|
||
}
|
||
} finally {
|
||
// 确保重置标志
|
||
graphStore.moveToSelectedNode = false
|
||
}
|
||
}
|
||
})
|
||
|
||
// 监听统计数据变化,通知父组件
|
||
watch(stats, (newStats) => {
|
||
emit('update:stats', newStats)
|
||
}, { deep: true, immediate: true })
|
||
|
||
// 暴露给父组件的方法
|
||
defineExpose({
|
||
loadFullGraph,
|
||
clearGraph
|
||
})
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.knowledge-graph-viewer {
|
||
position: relative;
|
||
width: 100%;
|
||
// height: 100vh;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.control-panel {
|
||
background: white;
|
||
padding: 6px 16px; /* Reduced from 16px */
|
||
border-bottom: none;
|
||
display: flex;
|
||
gap: 12px; /* Reduced from 16px */
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.database-section,
|
||
.search-section,
|
||
.filter-section,
|
||
.stats-section {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px; /* Reduced from 8px */
|
||
}
|
||
|
||
.stats-section {
|
||
margin-left: auto;
|
||
}
|
||
|
||
.sigma-container {
|
||
flex: 1;
|
||
// background: white;
|
||
position: relative; /* 确保子元素可以相对于此容器定位 */
|
||
// border: 1px solid var(--main-20);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
|
||
&.loading {
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.graph-overlay-stats {
|
||
position: absolute;
|
||
left: 16px;
|
||
bottom: 16px;
|
||
display: flex;
|
||
gap: 8px;
|
||
pointer-events: none;
|
||
z-index: 1100;
|
||
}
|
||
|
||
.detail-panel {
|
||
position: absolute;
|
||
width: 240px; /* Reduced from 300px */
|
||
background: white;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px; /* Reduced from 8px */
|
||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08); /* Reduced shadow */
|
||
transition: box-shadow 0.2s ease;
|
||
cursor: move;
|
||
z-index: 1000;
|
||
|
||
&:hover {
|
||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); /* Reduced shadow */
|
||
}
|
||
|
||
&.node-panel {
|
||
border-left: 4px solid #52c41a; // 绿色边框标识节点面板
|
||
}
|
||
|
||
&.edge-panel {
|
||
border-left: 4px solid #1890ff; // 蓝色边框标识边面板
|
||
}
|
||
}
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px; /* Reduced from 8px */
|
||
padding: 8px 12px; /* Reduced from 12px 16px */
|
||
border-bottom: 1px solid #f0f0f0;
|
||
background: #fafafa;
|
||
border-radius: 6px 6px 0 0; /* Reduced from 8px */
|
||
|
||
h4 {
|
||
margin: 0;
|
||
font-size: 12px; /* Reduced from 14px */
|
||
font-weight: 600;
|
||
flex: 1;
|
||
color: #262626;
|
||
}
|
||
}
|
||
|
||
.panel-type-indicator {
|
||
width: 6px; /* Reduced from 8px */
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
|
||
&.node-indicator {
|
||
background: #52c41a;
|
||
}
|
||
|
||
&.edge-indicator {
|
||
background: #1890ff;
|
||
}
|
||
}
|
||
|
||
.detail-content {
|
||
padding: 10px; /* Reduced from 16px */
|
||
max-height: 300px; /* Reduced from 400px */
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.detail-item {
|
||
display: flex;
|
||
margin-bottom: 8px; /* Reduced from 12px */
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.detail-label {
|
||
min-width: 50px; /* Reduced from 60px */
|
||
font-weight: 600;
|
||
color: #595959;
|
||
font-size: 11px; /* Reduced from 12px */
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.detail-value {
|
||
color: #262626;
|
||
font-size: 11px; /* Reduced from 12px */
|
||
word-break: break-word;
|
||
line-height: 1.3; /* Reduced from 1.4 */
|
||
}
|
||
|
||
.detail-actions {
|
||
margin-top: 12px; /* Reduced from 16px */
|
||
padding-top: 8px; /* Reduced from 12px */
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.graph-controls {
|
||
position: absolute;
|
||
bottom: 16px; /* Reduced from 20px */
|
||
right: 16px;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.control-group {
|
||
background: rgba(255, 255, 255, 0.8);
|
||
backdrop-filter: blur(8px);
|
||
border: 2px solid white;
|
||
border-radius: 16px; /* Reduced from 20px */
|
||
padding: 2px; /* Reduced from 4px */
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); /* Reduced shadow */
|
||
display: flex;
|
||
gap: 1px; /* Reduced from 2px */
|
||
}
|
||
|
||
.control-btn {
|
||
width: 28px; /* Reduced from 32px */
|
||
height: 28px;
|
||
border-radius: 14px; /* Reduced from 16px */
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
background: transparent;
|
||
color: #595959;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
background: color-mix(in srgb, var(--main-color) 10%, transparent);
|
||
color: var(--main-color);
|
||
}
|
||
}
|
||
|
||
.legend {
|
||
position: absolute;
|
||
right: 12px; /* Reduced from 16px */
|
||
top: 60px; /* Reduced from 80px */
|
||
background: rgba(255, 255, 255, 0.8);
|
||
backdrop-filter: blur(8px);
|
||
border: 2px solid white;
|
||
border-radius: 6px; /* Reduced from 8px */
|
||
width: 150px; /* Reduced from 180px */
|
||
max-height: 250px; /* Reduced from 300px */
|
||
z-index: 1000;
|
||
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08); /* Reduced shadow */
|
||
overflow: hidden;
|
||
}
|
||
|
||
.legend-header {
|
||
background: #fafafa;
|
||
padding: 6px 10px; /* Reduced from 8px 12px */
|
||
border-bottom: 1px solid #f0f0f0;
|
||
|
||
h4 {
|
||
margin: 0;
|
||
font-size: 12px; /* Reduced from 13px */
|
||
font-weight: 600;
|
||
color: #262626;
|
||
}
|
||
}
|
||
|
||
.legend-content {
|
||
padding: 6px 10px; /* Reduced from 8px 12px */
|
||
max-height: 200px; /* Reduced from 240px */
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
|
||
/* 自定义滚动条样式 */
|
||
&::-webkit-scrollbar {
|
||
width: 3px; /* Reduced from 4px */
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background: #d9d9d9;
|
||
border-radius: 2px;
|
||
|
||
&:hover {
|
||
background: #bfbfbf;
|
||
}
|
||
}
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px; /* Reduced from 8px */
|
||
padding: 2px 4px; /* Reduced from 4px 6px */
|
||
margin: 1px 0; /* Reduced from 2px */
|
||
border-radius: 3px; /* Reduced from 4px */
|
||
font-size: 11px; /* Reduced from 12px */
|
||
min-width: 0;
|
||
transition: background-color 0.2s ease;
|
||
|
||
span {
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex: 1;
|
||
min-width: 0;
|
||
color: #595959;
|
||
}
|
||
|
||
&:hover {
|
||
background-color: #f5f5f5;
|
||
}
|
||
}
|
||
|
||
.legend-color {
|
||
width: 8px; /* Reduced from 10px */
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.loading-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(255, 255, 255, 0.8);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 2000;
|
||
}
|
||
|
||
.loading-content {
|
||
text-align: center;
|
||
|
||
p {
|
||
margin-top: 12px; /* Reduced from 16px */
|
||
color: #666;
|
||
font-size: 12px; /* Added smaller font size */
|
||
}
|
||
}
|
||
</style>
|