2025-09-03 16:06:56 +08:00
|
|
|
<template>
|
|
|
|
|
<div class="graph-canvas-container" ref="rootEl">
|
|
|
|
|
<div v-show="graphData.nodes.length > 0" class="graph-canvas" ref="container"></div>
|
|
|
|
|
<div class="slots">
|
|
|
|
|
<div v-if="$slots.top" class="overlay top">
|
|
|
|
|
<slot name="top" />
|
|
|
|
|
</div>
|
2025-11-23 01:39:44 +08:00
|
|
|
<div class="canvas-content">
|
2025-09-03 16:06:56 +08:00
|
|
|
<slot name="content" />
|
|
|
|
|
</div>
|
2025-12-20 15:44:37 +08:00
|
|
|
<!-- Statistical Info Panel -->
|
|
|
|
|
<div class="graph-stats-panel" v-if="graphData.nodes.length > 0">
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">节点</span>
|
|
|
|
|
<span class="stat-value">{{ graphData.nodes.length }}</span>
|
|
|
|
|
<span v-if="graphInfo?.node_count" class="stat-total">/ {{ graphInfo.node_count }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
<span class="stat-label">边</span>
|
|
|
|
|
<span class="stat-value">{{ graphData.edges.length }}</span>
|
|
|
|
|
<span v-if="graphInfo?.edge_count" class="stat-total">/ {{ graphInfo.edge_count }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-09-03 16:06:56 +08:00
|
|
|
<div v-if="$slots.bottom" class="overlay bottom">
|
|
|
|
|
<slot name="bottom" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { Graph } from '@antv/g6'
|
|
|
|
|
import { onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
|
2025-11-23 01:39:44 +08:00
|
|
|
import { useThemeStore } from '@/stores/theme'
|
2025-09-03 16:06:56 +08:00
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
graphData: {
|
|
|
|
|
type: Object,
|
|
|
|
|
required: true,
|
|
|
|
|
default: () => ({ nodes: [], edges: [] })
|
|
|
|
|
},
|
2025-12-20 15:44:37 +08:00
|
|
|
graphInfo: {
|
|
|
|
|
type: Object,
|
|
|
|
|
default: () => ({})
|
|
|
|
|
},
|
2025-09-03 16:06:56 +08:00
|
|
|
labelField: { type: String, default: 'name' },
|
|
|
|
|
autoFit: { type: Boolean, default: true },
|
|
|
|
|
autoResize: { type: Boolean, default: true },
|
|
|
|
|
layoutOptions: { type: Object, default: () => ({}) },
|
|
|
|
|
nodeStyleOptions: { type: Object, default: () => ({}) },
|
|
|
|
|
edgeStyleOptions: { type: Object, default: () => ({}) },
|
|
|
|
|
enableFocusNeighbor: { type: Boolean, default: true },
|
2025-09-11 02:44:35 +08:00
|
|
|
sizeByDegree: { type: Boolean, default: true },
|
|
|
|
|
highlightKeywords: { type: Array, default: () => [] }
|
2025-09-03 16:06:56 +08:00
|
|
|
})
|
|
|
|
|
|
2025-12-15 23:25:56 +08:00
|
|
|
const emit = defineEmits(['ready', 'data-rendered', 'node-click', 'edge-click', 'canvas-click'])
|
2025-09-03 16:06:56 +08:00
|
|
|
|
|
|
|
|
const container = ref(null)
|
|
|
|
|
const rootEl = ref(null)
|
2025-11-23 01:39:44 +08:00
|
|
|
const themeStore = useThemeStore()
|
2025-09-03 16:06:56 +08:00
|
|
|
let graphInstance = null
|
|
|
|
|
let resizeObserver = null
|
|
|
|
|
let renderTimeout = null
|
|
|
|
|
let retryCount = 0
|
|
|
|
|
const MAX_RETRIES = 5
|
|
|
|
|
|
|
|
|
|
const defaultLayout = {
|
|
|
|
|
type: 'd3-force',
|
|
|
|
|
preventOverlap: true,
|
|
|
|
|
alphaDecay: 0.1,
|
|
|
|
|
alphaMin: 0.01,
|
2025-12-20 15:53:27 +08:00
|
|
|
velocityDecay: 0.6,
|
|
|
|
|
iterations: 150,
|
2025-09-03 16:06:56 +08:00
|
|
|
force: {
|
|
|
|
|
center: { x: 0.5, y: 0.5, strength: 0.1 },
|
2025-12-20 15:53:27 +08:00
|
|
|
charge: { strength: -400, distanceMax: 600 },
|
2026-01-15 06:01:34 +08:00
|
|
|
link: { distance: 100, strength: 0.8 }
|
2025-09-03 16:06:56 +08:00
|
|
|
},
|
2026-01-15 06:01:34 +08:00
|
|
|
collide: { radius: 40, strength: 0.8, iterations: 3 }
|
2025-09-03 16:06:56 +08:00
|
|
|
}
|
|
|
|
|
|
2025-11-23 01:39:44 +08:00
|
|
|
// CSS 变量解析工具函数
|
|
|
|
|
function getCSSVariable(variableName, element = document.documentElement) {
|
|
|
|
|
return getComputedStyle(element).getPropertyValue(variableName).trim()
|
|
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
|
|
|
|
|
function formatData() {
|
|
|
|
|
const data = props.graphData || { nodes: [], edges: [] }
|
|
|
|
|
const degrees = new Map()
|
|
|
|
|
|
|
|
|
|
for (const n of data.nodes) {
|
|
|
|
|
degrees.set(String(n.id), 0)
|
|
|
|
|
}
|
|
|
|
|
for (const e of data.edges) {
|
|
|
|
|
const s = String(e.source_id)
|
|
|
|
|
const t = String(e.target_id)
|
|
|
|
|
degrees.set(s, (degrees.get(s) || 0) + 1)
|
|
|
|
|
degrees.set(t, (degrees.get(t) || 0) + 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nodes = (data.nodes || []).map((n) => ({
|
|
|
|
|
id: String(n.id),
|
|
|
|
|
data: {
|
|
|
|
|
label: n[props.labelField] ?? n.name ?? String(n.id),
|
|
|
|
|
degree: degrees.get(String(n.id)) || 0,
|
2025-12-15 23:25:56 +08:00
|
|
|
original: n // 保存原始数据
|
2026-01-15 06:01:34 +08:00
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const edges = (data.edges || []).map((e, idx) => ({
|
|
|
|
|
id: e.id ? String(e.id) : `edge-${idx}`,
|
|
|
|
|
source: String(e.source_id),
|
|
|
|
|
target: String(e.target_id),
|
2025-12-16 10:28:44 +08:00
|
|
|
data: {
|
2025-12-15 23:25:56 +08:00
|
|
|
label: e.type ?? '',
|
|
|
|
|
original: e // 保存原始数据
|
2026-01-15 06:01:34 +08:00
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
return { nodes, edges }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initGraph() {
|
|
|
|
|
if (!container.value) return
|
|
|
|
|
|
|
|
|
|
const width = container.value.offsetWidth
|
|
|
|
|
const height = container.value.offsetHeight
|
|
|
|
|
|
|
|
|
|
if (width === 0 && height === 0) {
|
|
|
|
|
if (retryCount < MAX_RETRIES) {
|
|
|
|
|
retryCount++
|
|
|
|
|
clearTimeout(renderTimeout)
|
|
|
|
|
renderTimeout = setTimeout(initGraph, 200)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
retryCount = 0
|
|
|
|
|
container.value.innerHTML = ''
|
|
|
|
|
|
|
|
|
|
if (graphInstance) {
|
2026-01-15 06:01:34 +08:00
|
|
|
try {
|
|
|
|
|
graphInstance.destroy()
|
|
|
|
|
} catch (e) {}
|
2025-09-03 16:06:56 +08:00
|
|
|
graphInstance = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
graphInstance = new Graph({
|
|
|
|
|
container: container.value,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
autoFit: props.autoFit,
|
|
|
|
|
autoResize: props.autoResize,
|
|
|
|
|
layout: { ...defaultLayout, ...props.layoutOptions },
|
|
|
|
|
node: {
|
|
|
|
|
type: 'circle',
|
|
|
|
|
style: {
|
|
|
|
|
labelText: (d) => d.data.label,
|
2025-11-23 01:39:44 +08:00
|
|
|
labelFill: getCSSVariable('--gray-700'),
|
|
|
|
|
labelWordWrap: true, // enable label ellipsis
|
|
|
|
|
labelMaxWidth: '300%',
|
2026-01-15 06:01:34 +08:00
|
|
|
size: (d) => {
|
2025-09-03 16:06:56 +08:00
|
|
|
if (!props.sizeByDegree) return 24
|
|
|
|
|
const deg = d.data.degree || 0
|
|
|
|
|
return Math.min(15 + deg * 5, 50)
|
|
|
|
|
},
|
2025-11-23 01:39:44 +08:00
|
|
|
opacity: 0.9,
|
|
|
|
|
stroke: getCSSVariable('--color-bg-container'),
|
2025-09-03 16:06:56 +08:00
|
|
|
lineWidth: 1.5,
|
2025-11-23 01:39:44 +08:00
|
|
|
shadowColor: getCSSVariable('--gray-400'),
|
2025-09-03 16:06:56 +08:00
|
|
|
shadowBlur: 4,
|
2026-01-15 06:01:34 +08:00
|
|
|
...(props.nodeStyleOptions.style || {})
|
2025-09-03 16:06:56 +08:00
|
|
|
},
|
|
|
|
|
palette: props.nodeStyleOptions.palette || {
|
|
|
|
|
field: 'label',
|
2025-11-23 01:39:44 +08:00
|
|
|
color: [
|
2026-01-15 06:01:34 +08:00
|
|
|
'#60a5fa',
|
|
|
|
|
'#34d399',
|
|
|
|
|
'#f59e0b',
|
|
|
|
|
'#f472b6',
|
|
|
|
|
'#22d3ee',
|
|
|
|
|
'#a78bfa',
|
|
|
|
|
'#f97316',
|
|
|
|
|
'#4ade80',
|
|
|
|
|
'#f43f5e',
|
|
|
|
|
'#2dd4bf'
|
|
|
|
|
]
|
|
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
},
|
|
|
|
|
edge: {
|
2025-11-23 01:39:44 +08:00
|
|
|
type: 'quadratic',
|
2025-09-03 16:06:56 +08:00
|
|
|
style: {
|
|
|
|
|
labelText: (d) => d.data.label,
|
2025-11-23 01:39:44 +08:00
|
|
|
labelFill: getCSSVariable('--gray-800'),
|
|
|
|
|
labelBackground: true,
|
|
|
|
|
labelBackgroundFill: getCSSVariable('--gray-100'),
|
|
|
|
|
stroke: getCSSVariable('--gray-400'),
|
|
|
|
|
opacity: 0.8,
|
2025-09-03 16:06:56 +08:00
|
|
|
lineWidth: 1.2,
|
|
|
|
|
endArrow: true,
|
2026-01-15 06:01:34 +08:00
|
|
|
...(props.edgeStyleOptions.style || {})
|
|
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
},
|
2025-11-23 01:39:44 +08:00
|
|
|
behaviors: [
|
|
|
|
|
'drag-element',
|
|
|
|
|
'zoom-canvas',
|
|
|
|
|
'drag-canvas',
|
|
|
|
|
'hover-activate',
|
|
|
|
|
{
|
|
|
|
|
type: 'click-select',
|
|
|
|
|
degree: 1,
|
|
|
|
|
state: 'selected', // 选中的状态
|
|
|
|
|
neighborState: 'active', // 相邻节点附着状态
|
|
|
|
|
unselectedState: 'inactive', // 未选中节点状态
|
|
|
|
|
multiple: true,
|
|
|
|
|
trigger: ['shift'],
|
2025-12-15 23:25:56 +08:00
|
|
|
// 禁用默认的选中效果,避免与自定义事件冲突
|
2026-01-15 06:01:34 +08:00
|
|
|
disableDefault: false
|
2025-11-23 01:39:44 +08:00
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
]
|
2025-09-03 16:06:56 +08:00
|
|
|
})
|
2025-12-15 23:25:56 +08:00
|
|
|
|
|
|
|
|
// 绑定事件
|
|
|
|
|
graphInstance.on('node:click', (evt) => {
|
|
|
|
|
const { target } = evt
|
|
|
|
|
// 获取节点ID
|
|
|
|
|
const nodeId = target.id
|
|
|
|
|
const nodeData = graphInstance.getNodeData(nodeId)
|
|
|
|
|
emit('node-click', nodeData)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
graphInstance.on('edge:click', (evt) => {
|
|
|
|
|
const { target } = evt
|
|
|
|
|
const edgeId = target.id
|
|
|
|
|
const edgeData = graphInstance.getEdgeData(edgeId)
|
|
|
|
|
emit('edge-click', edgeData)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
graphInstance.on('canvas:click', (evt) => {
|
|
|
|
|
// 只有点击画布空白处才触发
|
|
|
|
|
if (!evt.target) {
|
2026-01-15 06:01:34 +08:00
|
|
|
emit('canvas-click')
|
2025-12-15 23:25:56 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2025-09-03 16:06:56 +08:00
|
|
|
emit('ready', graphInstance)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setGraphData() {
|
|
|
|
|
if (!graphInstance) initGraph()
|
|
|
|
|
if (!graphInstance) return
|
|
|
|
|
const data = formatData()
|
2025-12-19 21:18:46 +08:00
|
|
|
|
|
|
|
|
console.log('开始设置图谱数据:', {
|
|
|
|
|
nodes: data.nodes.length,
|
|
|
|
|
edges: data.edges.length
|
|
|
|
|
})
|
|
|
|
|
|
2025-09-03 16:06:56 +08:00
|
|
|
graphInstance.setData(data)
|
|
|
|
|
graphInstance.render()
|
2025-09-11 02:44:35 +08:00
|
|
|
|
2025-12-19 21:18:46 +08:00
|
|
|
// 手动触发布局重新计算,确保节点分布
|
2025-09-03 16:06:56 +08:00
|
|
|
setTimeout(() => {
|
2025-12-19 21:18:46 +08:00
|
|
|
try {
|
|
|
|
|
if (graphInstance && graphInstance.layout) {
|
|
|
|
|
graphInstance.layout()
|
|
|
|
|
console.log('触发布局重新计算')
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('布局重新计算失败:', error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 等待力导向布局稳定后再应用高亮
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
applyHighlightKeywords()
|
|
|
|
|
emit('data-rendered')
|
|
|
|
|
console.log('图谱渲染完成,布局已稳定')
|
|
|
|
|
}, 1500)
|
2026-01-15 06:01:34 +08:00
|
|
|
}, 10) // 等待 10ms 确保布局完成
|
2025-09-03 16:06:56 +08:00
|
|
|
}
|
|
|
|
|
|
2025-09-11 02:44:35 +08:00
|
|
|
// 关键词高亮功能
|
|
|
|
|
function applyHighlightKeywords() {
|
|
|
|
|
if (!graphInstance || !props.highlightKeywords || props.highlightKeywords.length === 0) return
|
|
|
|
|
|
|
|
|
|
const { nodes } = graphInstance.getData()
|
|
|
|
|
const updates = {}
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
nodes.forEach((node) => {
|
2025-09-11 02:44:35 +08:00
|
|
|
const nodeLabel = node.data.label || node.data[props.labelField] || String(node.id)
|
2026-01-15 06:01:34 +08:00
|
|
|
const shouldHighlight = props.highlightKeywords.some(
|
|
|
|
|
(keyword) => keyword.trim() !== '' && nodeLabel.toLowerCase().includes(keyword.toLowerCase())
|
2025-09-11 02:44:35 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (shouldHighlight) {
|
|
|
|
|
updates[node.id] = ['highlighted']
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
|
|
|
graphInstance.setElementState(updates)
|
|
|
|
|
graphInstance.draw()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 清除高亮
|
|
|
|
|
function clearHighlights() {
|
|
|
|
|
if (!graphInstance) return
|
|
|
|
|
|
|
|
|
|
const { nodes } = graphInstance.getData()
|
|
|
|
|
const updates = {}
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
nodes.forEach((node) => {
|
2025-09-11 02:44:35 +08:00
|
|
|
updates[node.id] = []
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (Object.keys(updates).length > 0) {
|
|
|
|
|
graphInstance.setElementState(updates)
|
|
|
|
|
graphInstance.draw()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 16:06:56 +08:00
|
|
|
function renderGraph() {
|
|
|
|
|
if (!graphInstance) initGraph()
|
|
|
|
|
setGraphData()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshGraph() {
|
|
|
|
|
if (graphInstance) {
|
2026-01-15 06:01:34 +08:00
|
|
|
try {
|
|
|
|
|
graphInstance.destroy()
|
|
|
|
|
} catch (e) {}
|
2025-09-03 16:06:56 +08:00
|
|
|
graphInstance = null
|
|
|
|
|
}
|
|
|
|
|
if (container.value) container.value.innerHTML = ''
|
|
|
|
|
retryCount = 0
|
|
|
|
|
clearTimeout(renderTimeout)
|
2026-01-15 06:01:34 +08:00
|
|
|
renderTimeout = setTimeout(() => {
|
|
|
|
|
renderGraph()
|
|
|
|
|
}, 300)
|
2025-09-03 16:06:56 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
function fitView() {
|
|
|
|
|
if (graphInstance)
|
|
|
|
|
try {
|
|
|
|
|
graphInstance.fitView()
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
function fitCenter() {
|
|
|
|
|
if (graphInstance)
|
|
|
|
|
try {
|
|
|
|
|
graphInstance.fitCenter()
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
function getInstance() {
|
|
|
|
|
return graphInstance
|
|
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
|
|
|
|
|
async function focusNode(id) {
|
|
|
|
|
if (!graphInstance || !props.enableFocusNeighbor) return
|
|
|
|
|
const { nodes, edges } = graphInstance.getData()
|
2026-01-15 06:01:34 +08:00
|
|
|
const nodeIds = nodes.map((n) => n.id)
|
|
|
|
|
const edgeIds = edges.map((e) => e.id)
|
2025-09-03 16:06:56 +08:00
|
|
|
const updates = {}
|
2026-01-15 06:01:34 +08:00
|
|
|
nodeIds.forEach((nid) => (updates[nid] = ['hidden']))
|
|
|
|
|
edgeIds.forEach((eid) => (updates[eid] = ['hidden']))
|
2025-09-03 16:06:56 +08:00
|
|
|
const neighborSet = new Set()
|
|
|
|
|
const related = []
|
|
|
|
|
edges.forEach((e) => {
|
2026-01-15 06:01:34 +08:00
|
|
|
if (e.source === id) {
|
|
|
|
|
neighborSet.add(e.target)
|
|
|
|
|
related.push(e.id)
|
|
|
|
|
} else if (e.target === id) {
|
|
|
|
|
neighborSet.add(e.source)
|
|
|
|
|
related.push(e.id)
|
|
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
})
|
|
|
|
|
updates[id] = ['focus']
|
2026-01-15 06:01:34 +08:00
|
|
|
Array.from(neighborSet).forEach((nid) => (updates[nid] = ['focus']))
|
|
|
|
|
related.forEach((eid) => (updates[eid] = ['focus']))
|
2025-09-03 16:06:56 +08:00
|
|
|
await graphInstance.setElementState(updates)
|
|
|
|
|
await graphInstance.draw()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function clearFocus() {
|
|
|
|
|
if (!graphInstance) return
|
|
|
|
|
const { nodes, edges } = graphInstance.getData()
|
2026-01-15 06:01:34 +08:00
|
|
|
const nodeIds = nodes.map((n) => n.id)
|
|
|
|
|
const edgeIds = edges.map((e) => e.id)
|
2025-09-03 16:06:56 +08:00
|
|
|
const updates = {}
|
2026-01-15 06:01:34 +08:00
|
|
|
nodeIds.forEach((nid) => (updates[nid] = []))
|
|
|
|
|
edgeIds.forEach((eid) => (updates[eid] = []))
|
2025-09-03 16:06:56 +08:00
|
|
|
await graphInstance.setElementState(updates)
|
|
|
|
|
await graphInstance.draw()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
watch(
|
|
|
|
|
() => props.graphData,
|
|
|
|
|
() => {
|
|
|
|
|
clearTimeout(renderTimeout)
|
|
|
|
|
renderTimeout = setTimeout(() => setGraphData(), 50)
|
|
|
|
|
},
|
|
|
|
|
{ deep: true }
|
|
|
|
|
)
|
2025-09-03 16:06:56 +08:00
|
|
|
|
2025-09-11 02:44:35 +08:00
|
|
|
// 监听关键词变化
|
2026-01-15 06:01:34 +08:00
|
|
|
watch(
|
|
|
|
|
() => props.highlightKeywords,
|
|
|
|
|
() => {
|
|
|
|
|
if (graphInstance) {
|
|
|
|
|
clearHighlights()
|
|
|
|
|
setTimeout(() => applyHighlightKeywords(), 50)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ deep: true }
|
|
|
|
|
)
|
2025-09-11 02:44:35 +08:00
|
|
|
|
2025-11-23 01:39:44 +08:00
|
|
|
// 监听主题切换,重新加载图形
|
2026-01-15 06:01:34 +08:00
|
|
|
watch(
|
|
|
|
|
() => themeStore.isDark,
|
|
|
|
|
() => {
|
|
|
|
|
if (graphInstance) {
|
|
|
|
|
refreshGraph()
|
|
|
|
|
}
|
2025-11-23 01:39:44 +08:00
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
)
|
2025-11-23 01:39:44 +08:00
|
|
|
|
2025-09-03 16:06:56 +08:00
|
|
|
onMounted(() => {
|
|
|
|
|
// ResizeObserver 监听容器尺寸,自动重渲染
|
|
|
|
|
if (window.ResizeObserver) {
|
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
if (!container.value || !graphInstance) return
|
|
|
|
|
const width = container.value.offsetWidth
|
|
|
|
|
const height = container.value.offsetHeight
|
|
|
|
|
graphInstance.changeSize(width, height)
|
|
|
|
|
})
|
|
|
|
|
if (container.value) resizeObserver.observe(container.value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearTimeout(renderTimeout)
|
2026-01-15 06:01:34 +08:00
|
|
|
renderTimeout = setTimeout(() => {
|
|
|
|
|
renderGraph()
|
|
|
|
|
}, 300)
|
2025-09-03 16:06:56 +08:00
|
|
|
|
|
|
|
|
window.addEventListener('resize', refreshGraph)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
window.removeEventListener('resize', refreshGraph)
|
|
|
|
|
if (resizeObserver && container.value) resizeObserver.unobserve(container.value)
|
|
|
|
|
clearTimeout(renderTimeout)
|
2026-01-15 06:01:34 +08:00
|
|
|
try {
|
|
|
|
|
graphInstance?.destroy()
|
|
|
|
|
} catch (e) {}
|
2025-09-03 16:06:56 +08:00
|
|
|
graphInstance = null
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 暴露方法
|
2025-09-11 02:44:35 +08:00
|
|
|
defineExpose({
|
|
|
|
|
refreshGraph,
|
|
|
|
|
fitView,
|
|
|
|
|
fitCenter,
|
|
|
|
|
getInstance,
|
|
|
|
|
focusNode,
|
|
|
|
|
clearFocus,
|
|
|
|
|
setData: setGraphData,
|
|
|
|
|
applyHighlightKeywords,
|
|
|
|
|
clearHighlights
|
|
|
|
|
})
|
2025-09-03 16:06:56 +08:00
|
|
|
</script>
|
|
|
|
|
|
2025-09-04 01:47:09 +08:00
|
|
|
<style lang="less">
|
2025-09-03 16:06:56 +08:00
|
|
|
.graph-canvas-container {
|
|
|
|
|
position: relative;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
2025-12-16 10:28:44 +08:00
|
|
|
// background-color: var(--gray-0);
|
2025-09-03 16:06:56 +08:00
|
|
|
|
2025-09-04 01:47:09 +08:00
|
|
|
.graph-canvas {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-20 15:44:37 +08:00
|
|
|
.graph-stats-panel {
|
|
|
|
|
position: absolute;
|
|
|
|
|
bottom: 20px;
|
|
|
|
|
left: 20px;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
padding: 6px 12px;
|
|
|
|
|
background: var(--color-trans-light);
|
|
|
|
|
border: 1px solid var(--color-border-secondary);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
backdrop-filter: blur(4px);
|
|
|
|
|
|
|
|
|
|
.stat-item {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-total {
|
|
|
|
|
color: var(--color-text-quaternary);
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-03 16:06:56 +08:00
|
|
|
.slots {
|
2025-09-04 01:47:09 +08:00
|
|
|
// 让整层覆盖容器默认不接收指针事件(便于穿透到底下画布)
|
|
|
|
|
pointer-events: none;
|
2025-09-03 16:06:56 +08:00
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
z-index: 999;
|
2025-09-04 01:47:09 +08:00
|
|
|
|
|
|
|
|
.overlay {
|
|
|
|
|
width: 100%;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
flex-grow: 0;
|
|
|
|
|
pointer-events: auto;
|
|
|
|
|
|
2026-01-15 06:01:34 +08:00
|
|
|
&.top {
|
|
|
|
|
top: 0;
|
|
|
|
|
}
|
|
|
|
|
&.bottom {
|
|
|
|
|
bottom: 0;
|
|
|
|
|
}
|
2025-09-04 01:47:09 +08:00
|
|
|
}
|
2025-11-23 01:39:44 +08:00
|
|
|
.canvas-content {
|
2025-09-04 01:47:09 +08:00
|
|
|
// 中间内容层及其子元素全部穿透
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
flex: 1;
|
2025-11-23 01:39:44 +08:00
|
|
|
background: transparent !important;
|
2025-09-04 01:47:09 +08:00
|
|
|
}
|
2025-11-23 01:39:44 +08:00
|
|
|
.canvas-content * {
|
2025-09-04 01:47:09 +08:00
|
|
|
pointer-events: none;
|
|
|
|
|
}
|
2025-09-03 16:06:56 +08:00
|
|
|
}
|
|
|
|
|
}
|
2025-09-04 01:47:09 +08:00
|
|
|
|
2025-09-11 02:44:35 +08:00
|
|
|
/* 高亮节点的脉冲动画效果 */
|
|
|
|
|
@keyframes highlightPulse {
|
|
|
|
|
0% {
|
|
|
|
|
filter: brightness(1);
|
|
|
|
|
}
|
|
|
|
|
50% {
|
|
|
|
|
filter: brightness(1.3) drop-shadow(0 0 8px rgba(255, 0, 0, 0.8));
|
|
|
|
|
}
|
|
|
|
|
100% {
|
|
|
|
|
filter: brightness(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.highlight-animation {
|
|
|
|
|
animation: highlightPulse 2s infinite ease-in-out;
|
|
|
|
|
}
|
2026-01-15 06:01:34 +08:00
|
|
|
</style>
|