Files
microfish/frontend/src/components/GraphPanel.vue
666ghj 1f6f79c8aa Add gravitational forces to node simulation in GraphPanel.vue
- Introduced x and y forces to attract independent nodes towards the center, enhancing the overall layout and clustering of nodes in the graph visualization.
- Improved the simulation dynamics for a more cohesive visual representation of the graph.
2025-12-11 13:40:01 +08:00

1309 lines
35 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="graph-panel">
<div class="panel-header">
<span class="panel-title">Graph Relationship Visualization</span>
<!-- 顶部工具栏 (Internal Top Right) -->
<div class="header-tools">
<button class="tool-btn" @click="$emit('refresh')" :disabled="loading" title="刷新图谱">
<span class="icon-refresh" :class="{ 'spinning': loading }"></span>
<span class="btn-text">Refresh</span>
</button>
<button class="tool-btn" @click="$emit('toggle-maximize')" title="最大化/还原">
<span class="icon-maximize">×</span>
</button>
</div>
</div>
<div class="graph-container" ref="graphContainer">
<!-- 图谱可视化 -->
<div v-if="graphData" class="graph-view">
<svg ref="graphSvg" class="graph-svg"></svg>
<!-- 构建中提示 -->
<div v-if="currentPhase === 1" class="graph-building-hint">
<span class="building-dot"></span>
实时更新中...
</div>
<!-- 节点/边详情面板 -->
<div v-if="selectedItem" class="detail-panel">
<div class="detail-panel-header">
<span class="detail-title">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span>
<span v-if="selectedItem.type === 'node'" class="detail-type-badge" :style="{ background: selectedItem.color, color: '#fff' }">
{{ selectedItem.entityType }}
</span>
<button class="detail-close" @click="closeDetailPanel">×</button>
</div>
<!-- 节点详情 -->
<div v-if="selectedItem.type === 'node'" class="detail-content">
<div class="detail-row">
<span class="detail-label">Name:</span>
<span class="detail-value">{{ selectedItem.data.name }}</span>
</div>
<div class="detail-row">
<span class="detail-label">UUID:</span>
<span class="detail-value uuid-text">{{ selectedItem.data.uuid }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.created_at">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatDateTime(selectedItem.data.created_at) }}</span>
</div>
<!-- Properties -->
<div class="detail-section" v-if="selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0">
<div class="section-title">Properties:</div>
<div class="properties-list">
<div v-for="(value, key) in selectedItem.data.attributes" :key="key" class="property-item">
<span class="property-key">{{ key }}:</span>
<span class="property-value">{{ value || 'None' }}</span>
</div>
</div>
</div>
<!-- Summary -->
<div class="detail-section" v-if="selectedItem.data.summary">
<div class="section-title">Summary:</div>
<div class="summary-text">{{ selectedItem.data.summary }}</div>
</div>
<!-- Labels -->
<div class="detail-section" v-if="selectedItem.data.labels && selectedItem.data.labels.length > 0">
<div class="section-title">Labels:</div>
<div class="labels-list">
<span v-for="label in selectedItem.data.labels" :key="label" class="label-tag">
{{ label }}
</span>
</div>
</div>
</div>
<!-- 边详情 -->
<div v-else class="detail-content">
<!-- 自环组详情 -->
<template v-if="selectedItem.data.isSelfLoopGroup">
<div class="edge-relation-header self-loop-header">
{{ selectedItem.data.source_name }} - Self Relations
<span class="self-loop-count">{{ selectedItem.data.selfLoopCount }} items</span>
</div>
<div class="self-loop-list">
<div
v-for="(loop, idx) in selectedItem.data.selfLoopEdges"
:key="loop.uuid || idx"
class="self-loop-item"
:class="{ expanded: expandedSelfLoops.has(loop.uuid || idx) }"
>
<div
class="self-loop-item-header"
@click="toggleSelfLoop(loop.uuid || idx)"
>
<span class="self-loop-index">#{{ idx + 1 }}</span>
<span class="self-loop-name">{{ loop.name || loop.fact_type || 'RELATED' }}</span>
<span class="self-loop-toggle">{{ expandedSelfLoops.has(loop.uuid || idx) ? '' : '+' }}</span>
</div>
<div class="self-loop-item-content" v-show="expandedSelfLoops.has(loop.uuid || idx)">
<div class="detail-row" v-if="loop.uuid">
<span class="detail-label">UUID:</span>
<span class="detail-value uuid-text">{{ loop.uuid }}</span>
</div>
<div class="detail-row" v-if="loop.fact">
<span class="detail-label">Fact:</span>
<span class="detail-value fact-text">{{ loop.fact }}</span>
</div>
<div class="detail-row" v-if="loop.fact_type">
<span class="detail-label">Type:</span>
<span class="detail-value">{{ loop.fact_type }}</span>
</div>
<div class="detail-row" v-if="loop.created_at">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatDateTime(loop.created_at) }}</span>
</div>
<div v-if="loop.episodes && loop.episodes.length > 0" class="self-loop-episodes">
<span class="detail-label">Episodes:</span>
<div class="episodes-list compact">
<span v-for="ep in loop.episodes" :key="ep" class="episode-tag small">{{ ep }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 普通边详情 -->
<template v-else>
<div class="edge-relation-header">
{{ selectedItem.data.source_name }} {{ selectedItem.data.name || 'RELATED_TO' }} {{ selectedItem.data.target_name }}
</div>
<div class="detail-row">
<span class="detail-label">UUID:</span>
<span class="detail-value uuid-text">{{ selectedItem.data.uuid }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Label:</span>
<span class="detail-value">{{ selectedItem.data.name || 'RELATED_TO' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Type:</span>
<span class="detail-value">{{ selectedItem.data.fact_type || 'Unknown' }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.fact">
<span class="detail-label">Fact:</span>
<span class="detail-value fact-text">{{ selectedItem.data.fact }}</span>
</div>
<!-- Episodes -->
<div class="detail-section" v-if="selectedItem.data.episodes && selectedItem.data.episodes.length > 0">
<div class="section-title">Episodes:</div>
<div class="episodes-list">
<span v-for="ep in selectedItem.data.episodes" :key="ep" class="episode-tag">
{{ ep }}
</span>
</div>
</div>
<div class="detail-row" v-if="selectedItem.data.created_at">
<span class="detail-label">Created:</span>
<span class="detail-value">{{ formatDateTime(selectedItem.data.created_at) }}</span>
</div>
<div class="detail-row" v-if="selectedItem.data.valid_at">
<span class="detail-label">Valid From:</span>
<span class="detail-value">{{ formatDateTime(selectedItem.data.valid_at) }}</span>
</div>
</template>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="loading" class="graph-state">
<div class="loading-spinner"></div>
<p>图谱数据加载中...</p>
</div>
<!-- 等待/空状态 -->
<div v-else class="graph-state">
<div class="empty-icon"></div>
<p class="empty-text">等待本体生成...</p>
</div>
</div>
<!-- 底部图例 (Bottom Left) -->
<div v-if="graphData && entityTypes.length" class="graph-legend">
<span class="legend-title">Entity Types</span>
<div class="legend-items">
<div class="legend-item" v-for="type in entityTypes" :key="type.name">
<span class="legend-dot" :style="{ background: type.color }"></span>
<span class="legend-label">{{ type.name }}</span>
</div>
</div>
</div>
<!-- 显示边标签开关 -->
<div v-if="graphData" class="edge-labels-toggle">
<label class="toggle-switch">
<input type="checkbox" v-model="showEdgeLabels" />
<span class="slider"></span>
</label>
<span class="toggle-label">Show Edge Labels</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import * as d3 from 'd3'
const props = defineProps({
graphData: Object,
loading: Boolean,
currentPhase: Number
})
const emit = defineEmits(['refresh', 'toggle-maximize'])
const graphContainer = ref(null)
const graphSvg = ref(null)
const selectedItem = ref(null)
const showEdgeLabels = ref(true) // 默认显示边标签
const expandedSelfLoops = ref(new Set()) // 展开的自环项
// 切换自环项展开/折叠状态
const toggleSelfLoop = (id) => {
const newSet = new Set(expandedSelfLoops.value)
if (newSet.has(id)) {
newSet.delete(id)
} else {
newSet.add(id)
}
expandedSelfLoops.value = newSet
}
// 计算实体类型用于图例
const entityTypes = computed(() => {
if (!props.graphData?.nodes) return []
const typeMap = {}
// 美观的颜色调色板
const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']
props.graphData.nodes.forEach(node => {
const type = node.labels?.find(l => l !== 'Entity') || 'Entity'
if (!typeMap[type]) {
typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }
}
typeMap[type].count++
})
return Object.values(typeMap)
})
// 格式化时间
const formatDateTime = (dateStr) => {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
})
} catch {
return dateStr
}
}
const closeDetailPanel = () => {
selectedItem.value = null
expandedSelfLoops.value = new Set() // 重置展开状态
}
let currentSimulation = null
let linkLabelsRef = null
let linkLabelBgRef = null
const renderGraph = () => {
if (!graphSvg.value || !props.graphData) return
// 停止之前的仿真
if (currentSimulation) {
currentSimulation.stop()
}
const container = graphContainer.value
const width = container.clientWidth
const height = container.clientHeight
const svg = d3.select(graphSvg.value)
.attr('width', width)
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
svg.selectAll('*').remove()
const nodesData = props.graphData.nodes || []
const edgesData = props.graphData.edges || []
if (nodesData.length === 0) return
// Prep data
const nodeMap = {}
nodesData.forEach(n => nodeMap[n.uuid] = n)
const nodes = nodesData.map(n => ({
id: n.uuid,
name: n.name || 'Unnamed',
type: n.labels?.find(l => l !== 'Entity') || 'Entity',
rawData: n
}))
const nodeIds = new Set(nodes.map(n => n.id))
// 处理边数据,计算同一对节点间的边数量和索引
const edgePairCount = {}
const selfLoopEdges = {} // 按节点分组的自环边
const tempEdges = edgesData
.filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid))
// 统计每对节点之间的边数量,收集自环边
tempEdges.forEach(e => {
if (e.source_node_uuid === e.target_node_uuid) {
// 自环 - 收集到数组中
if (!selfLoopEdges[e.source_node_uuid]) {
selfLoopEdges[e.source_node_uuid] = []
}
selfLoopEdges[e.source_node_uuid].push({
...e,
source_name: nodeMap[e.source_node_uuid]?.name,
target_name: nodeMap[e.target_node_uuid]?.name
})
} else {
const pairKey = [e.source_node_uuid, e.target_node_uuid].sort().join('_')
edgePairCount[pairKey] = (edgePairCount[pairKey] || 0) + 1
}
})
// 记录当前处理到每对节点的第几条边
const edgePairIndex = {}
const processedSelfLoopNodes = new Set() // 已处理的自环节点
const edges = []
tempEdges.forEach(e => {
const isSelfLoop = e.source_node_uuid === e.target_node_uuid
if (isSelfLoop) {
// 自环边 - 每个节点只添加一条合并的自环
if (processedSelfLoopNodes.has(e.source_node_uuid)) {
return // 已处理过,跳过
}
processedSelfLoopNodes.add(e.source_node_uuid)
const allSelfLoops = selfLoopEdges[e.source_node_uuid]
const nodeName = nodeMap[e.source_node_uuid]?.name || 'Unknown'
edges.push({
source: e.source_node_uuid,
target: e.target_node_uuid,
type: 'SELF_LOOP',
name: `Self Relations (${allSelfLoops.length})`,
curvature: 0,
isSelfLoop: true,
rawData: {
isSelfLoopGroup: true,
source_name: nodeName,
target_name: nodeName,
selfLoopCount: allSelfLoops.length,
selfLoopEdges: allSelfLoops // 存储所有自环边的详细信息
}
})
return
}
const pairKey = [e.source_node_uuid, e.target_node_uuid].sort().join('_')
const totalCount = edgePairCount[pairKey]
const currentIndex = edgePairIndex[pairKey] || 0
edgePairIndex[pairKey] = currentIndex + 1
// 判断边的方向是否与标准化方向一致源UUID < 目标UUID
const isReversed = e.source_node_uuid > e.target_node_uuid
// 计算曲率:多条边时分散开,单条边为直线
let curvature = 0
if (totalCount > 1) {
// 均匀分布曲率,确保明显区分
// 曲率范围根据边数量增加,边越多曲率范围越大
const curvatureRange = Math.min(1.2, 0.6 + totalCount * 0.15)
curvature = ((currentIndex / (totalCount - 1)) - 0.5) * curvatureRange * 2
// 如果边的方向与标准化方向相反,翻转曲率
// 这样确保所有边在同一参考系下分布,不会因方向不同而重叠
if (isReversed) {
curvature = -curvature
}
}
edges.push({
source: e.source_node_uuid,
target: e.target_node_uuid,
type: e.fact_type || e.name || 'RELATED',
name: e.name || e.fact_type || 'RELATED',
curvature,
isSelfLoop: false,
pairIndex: currentIndex,
pairTotal: totalCount,
rawData: {
...e,
source_name: nodeMap[e.source_node_uuid]?.name,
target_name: nodeMap[e.target_node_uuid]?.name
}
})
})
// Color scale
const colorMap = {}
entityTypes.value.forEach(t => colorMap[t.name] = t.color)
const getColor = (type) => colorMap[type] || '#999'
// Simulation - 根据边数量动态调整节点间距
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(edges).id(d => d.id).distance(d => {
// 根据这对节点之间的边数量动态调整距离
// 基础距离 150每多一条边增加 40
const baseDistance = 150
const edgeCount = d.pairTotal || 1
return baseDistance + (edgeCount - 1) * 50
}))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide(50))
// 添加向中心的引力,让独立的节点群聚集到中心区域
.force('x', d3.forceX(width / 2).strength(0.04))
.force('y', d3.forceY(height / 2).strength(0.04))
currentSimulation = simulation
const g = svg.append('g')
// Zoom
svg.call(d3.zoom().extent([[0, 0], [width, height]]).scaleExtent([0.1, 4]).on('zoom', (event) => {
g.attr('transform', event.transform)
}))
// Links - 使用 path 支持曲线
const linkGroup = g.append('g').attr('class', 'links')
// 计算曲线路径
const getLinkPath = (d) => {
const sx = d.source.x, sy = d.source.y
const tx = d.target.x, ty = d.target.y
// 检测自环
if (d.isSelfLoop) {
// 自环:绘制一个圆弧从节点出发再返回
const loopRadius = 30
// 从节点右侧出发,绕一圈回来
const x1 = sx + 8 // 起点偏移
const y1 = sy - 4
const x2 = sx + 8 // 终点偏移
const y2 = sy + 4
// 使用圆弧绘制自环sweep-flag=1 顺时针)
return `M${x1},${y1} A${loopRadius},${loopRadius} 0 1,1 ${x2},${y2}`
}
if (d.curvature === 0) {
// 直线
return `M${sx},${sy} L${tx},${ty}`
}
// 计算曲线控制点 - 根据边数量和距离动态调整
const dx = tx - sx, dy = ty - sy
const dist = Math.sqrt(dx * dx + dy * dy)
// 垂直于连线方向的偏移,根据距离比例计算,保证曲线明显可见
// 边越多,偏移量占距离的比例越大
const pairTotal = d.pairTotal || 1
const offsetRatio = 0.25 + pairTotal * 0.05 // 基础25%每多一条边增加5%
const baseOffset = Math.max(35, dist * offsetRatio)
const offsetX = -dy / dist * d.curvature * baseOffset
const offsetY = dx / dist * d.curvature * baseOffset
const cx = (sx + tx) / 2 + offsetX
const cy = (sy + ty) / 2 + offsetY
return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`
}
// 计算曲线中点(用于标签定位)
const getLinkMidpoint = (d) => {
const sx = d.source.x, sy = d.source.y
const tx = d.target.x, ty = d.target.y
// 检测自环
if (d.isSelfLoop) {
// 自环标签位置:节点右侧
return { x: sx + 70, y: sy }
}
if (d.curvature === 0) {
return { x: (sx + tx) / 2, y: (sy + ty) / 2 }
}
// 二次贝塞尔曲线的中点 t=0.5
const dx = tx - sx, dy = ty - sy
const dist = Math.sqrt(dx * dx + dy * dy)
const pairTotal = d.pairTotal || 1
const offsetRatio = 0.25 + pairTotal * 0.05
const baseOffset = Math.max(35, dist * offsetRatio)
const offsetX = -dy / dist * d.curvature * baseOffset
const offsetY = dx / dist * d.curvature * baseOffset
const cx = (sx + tx) / 2 + offsetX
const cy = (sy + ty) / 2 + offsetY
// 二次贝塞尔曲线公式 B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2, t=0.5
const midX = 0.25 * sx + 0.5 * cx + 0.25 * tx
const midY = 0.25 * sy + 0.5 * cy + 0.25 * ty
return { x: midX, y: midY }
}
const link = linkGroup.selectAll('path')
.data(edges)
.enter().append('path')
.attr('stroke', '#C0C0C0')
.attr('stroke-width', 1.5)
.attr('fill', 'none')
.style('cursor', 'pointer')
.on('click', (event, d) => {
event.stopPropagation()
// 重置之前选中边的样式
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
// 高亮当前选中的边
d3.select(event.target).attr('stroke', '#3498db').attr('stroke-width', 3)
selectedItem.value = {
type: 'edge',
data: d.rawData
}
})
// Link labels background (白色背景使文字更清晰)
const linkLabelBg = linkGroup.selectAll('rect')
.data(edges)
.enter().append('rect')
.attr('fill', 'rgba(255,255,255,0.95)')
.attr('rx', 3)
.attr('ry', 3)
.style('cursor', 'pointer')
.style('pointer-events', 'all')
.style('display', showEdgeLabels.value ? 'block' : 'none')
.on('click', (event, d) => {
event.stopPropagation()
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
// 高亮对应的边
link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)
d3.select(event.target).attr('fill', 'rgba(52, 152, 219, 0.1)')
selectedItem.value = {
type: 'edge',
data: d.rawData
}
})
// Link labels
const linkLabels = linkGroup.selectAll('text')
.data(edges)
.enter().append('text')
.text(d => d.name)
.attr('font-size', '9px')
.attr('fill', '#666')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('cursor', 'pointer')
.style('pointer-events', 'all')
.style('font-family', 'system-ui, sans-serif')
.style('display', showEdgeLabels.value ? 'block' : 'none')
.on('click', (event, d) => {
event.stopPropagation()
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
// 高亮对应的边
link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)
d3.select(event.target).attr('fill', '#3498db')
selectedItem.value = {
type: 'edge',
data: d.rawData
}
})
// 保存引用供外部控制显隐
linkLabelsRef = linkLabels
linkLabelBgRef = linkLabelBg
// Nodes group
const nodeGroup = g.append('g').attr('class', 'nodes')
// Node circles
const node = nodeGroup.selectAll('circle')
.data(nodes)
.enter().append('circle')
.attr('r', 10)
.attr('fill', d => getColor(d.type))
.attr('stroke', '#fff')
.attr('stroke-width', 2.5)
.style('cursor', 'pointer')
.call(d3.drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0)
d.fx = null
d.fy = null
})
)
.on('click', (event, d) => {
event.stopPropagation()
// 重置所有节点样式
node.attr('stroke', '#fff').attr('stroke-width', 2.5)
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
// 高亮选中节点
d3.select(event.target).attr('stroke', '#E91E63').attr('stroke-width', 4)
// 高亮与此节点相连的边
link.filter(l => l.source.id === d.id || l.target.id === d.id)
.attr('stroke', '#E91E63')
.attr('stroke-width', 2.5)
selectedItem.value = {
type: 'node',
data: d.rawData,
entityType: d.type,
color: getColor(d.type)
}
})
.on('mouseenter', (event, d) => {
if (!selectedItem.value || selectedItem.value.data?.uuid !== d.rawData.uuid) {
d3.select(event.target).attr('stroke', '#333').attr('stroke-width', 3)
}
})
.on('mouseleave', (event, d) => {
if (!selectedItem.value || selectedItem.value.data?.uuid !== d.rawData.uuid) {
d3.select(event.target).attr('stroke', '#fff').attr('stroke-width', 2.5)
}
})
// Node Labels
const nodeLabels = nodeGroup.selectAll('text')
.data(nodes)
.enter().append('text')
.text(d => d.name.length > 8 ? d.name.substring(0, 8) + '…' : d.name)
.attr('font-size', '11px')
.attr('fill', '#333')
.attr('font-weight', '500')
.attr('dx', 14)
.attr('dy', 4)
.style('pointer-events', 'none')
.style('font-family', 'system-ui, sans-serif')
simulation.on('tick', () => {
// 更新曲线路径
link.attr('d', d => getLinkPath(d))
// 更新边标签位置(无旋转,水平显示更清晰)
linkLabels.each(function(d) {
const mid = getLinkMidpoint(d)
d3.select(this)
.attr('x', mid.x)
.attr('y', mid.y)
.attr('transform', '') // 移除旋转,保持水平
})
// 更新边标签背景
linkLabelBg.each(function(d, i) {
const mid = getLinkMidpoint(d)
const textEl = linkLabels.nodes()[i]
const bbox = textEl.getBBox()
d3.select(this)
.attr('x', mid.x - bbox.width / 2 - 4)
.attr('y', mid.y - bbox.height / 2 - 2)
.attr('width', bbox.width + 8)
.attr('height', bbox.height + 4)
.attr('transform', '') // 移除旋转
})
node
.attr('cx', d => d.x)
.attr('cy', d => d.y)
nodeLabels
.attr('x', d => d.x)
.attr('y', d => d.y)
})
// 点击空白处关闭详情面板
svg.on('click', () => {
selectedItem.value = null
node.attr('stroke', '#fff').attr('stroke-width', 2.5)
linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)
linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')
linkLabels.attr('fill', '#666')
})
}
watch(() => props.graphData, () => {
nextTick(renderGraph)
}, { deep: true })
// 监听边标签显示开关
watch(showEdgeLabels, (newVal) => {
if (linkLabelsRef) {
linkLabelsRef.style('display', newVal ? 'block' : 'none')
}
if (linkLabelBgRef) {
linkLabelBgRef.style('display', newVal ? 'block' : 'none')
}
})
const handleResize = () => {
nextTick(renderGraph)
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
if (currentSimulation) {
currentSimulation.stop()
}
})
</script>
<style scoped>
.graph-panel {
position: relative;
width: 100%;
height: 100%;
background-color: #FAFAFA;
background-image: radial-gradient(#D0D0D0 1.5px, transparent 1.5px);
background-size: 24px 24px;
overflow: hidden;
}
.panel-header {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 16px 20px;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(to bottom, rgba(255,255,255,0.95), rgba(255,255,255,0));
pointer-events: none;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: #333;
pointer-events: auto;
}
.header-tools {
pointer-events: auto;
display: flex;
gap: 10px;
align-items: center;
}
.tool-btn {
height: 32px;
padding: 0 12px;
border: 1px solid #E0E0E0;
background: #FFF;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
color: #666;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
font-size: 13px;
}
.tool-btn:hover {
background: #F5F5F5;
color: #000;
border-color: #CCC;
}
.tool-btn .btn-text {
font-size: 12px;
}
.icon-refresh.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.graph-container {
width: 100%;
height: 100%;
}
.graph-view, .graph-svg {
width: 100%;
height: 100%;
display: block;
}
.graph-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.2;
}
/* Entity Types Legend - Bottom Left */
.graph-legend {
position: absolute;
bottom: 24px;
left: 24px;
background: rgba(255,255,255,0.95);
padding: 12px 16px;
border-radius: 8px;
border: 1px solid #EAEAEA;
box-shadow: 0 4px 16px rgba(0,0,0,0.06);
z-index: 10;
}
.legend-title {
display: block;
font-size: 11px;
font-weight: 600;
color: #E91E63;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.legend-items {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
max-width: 320px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #555;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-label {
white-space: nowrap;
}
/* Edge Labels Toggle - Top Right */
.edge-labels-toggle {
position: absolute;
top: 60px;
right: 20px;
display: flex;
align-items: center;
gap: 10px;
background: #FFF;
padding: 8px 14px;
border-radius: 20px;
border: 1px solid #E0E0E0;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
z-index: 10;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #E0E0E0;
border-radius: 22px;
transition: 0.3s;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
input:checked + .slider {
background-color: #7B2D8E;
}
input:checked + .slider:before {
transform: translateX(18px);
}
.toggle-label {
font-size: 12px;
color: #666;
}
/* Detail Panel - Right Side */
.detail-panel {
position: absolute;
top: 60px;
right: 20px;
width: 320px;
max-height: calc(100% - 100px);
background: #FFF;
border: 1px solid #EAEAEA;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
z-index: 20;
display: flex;
flex-direction: column;
}
.detail-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
background: #FAFAFA;
border-bottom: 1px solid #EEE;
flex-shrink: 0;
}
.detail-title {
font-weight: 600;
color: #333;
font-size: 14px;
}
.detail-type-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
margin-left: auto;
margin-right: 12px;
}
.detail-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
line-height: 1;
padding: 0;
transition: color 0.2s;
}
.detail-close:hover {
color: #333;
}
.detail-content {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.detail-row {
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.detail-label {
color: #888;
font-size: 12px;
font-weight: 500;
min-width: 80px;
}
.detail-value {
color: #333;
flex: 1;
word-break: break-word;
}
.detail-value.uuid-text {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: #666;
}
.detail-value.fact-text {
line-height: 1.5;
color: #444;
}
.detail-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #F0F0F0;
}
.section-title {
font-size: 12px;
font-weight: 600;
color: #666;
margin-bottom: 10px;
}
.properties-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.property-item {
display: flex;
gap: 8px;
}
.property-key {
color: #888;
font-weight: 500;
min-width: 90px;
}
.property-value {
color: #333;
flex: 1;
}
.summary-text {
line-height: 1.6;
color: #444;
font-size: 12px;
}
.labels-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.label-tag {
display: inline-block;
padding: 4px 12px;
background: #F5F5F5;
border: 1px solid #E0E0E0;
border-radius: 16px;
font-size: 11px;
color: #555;
}
.episodes-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.episode-tag {
display: inline-block;
padding: 6px 10px;
background: #F8F8F8;
border: 1px solid #E8E8E8;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: #666;
word-break: break-all;
}
/* Edge relation header */
.edge-relation-header {
background: #F8F8F8;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
font-weight: 500;
color: #333;
line-height: 1.5;
word-break: break-word;
}
/* Building hint */
.graph-building-hint {
position: absolute;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.75);
color: #fff;
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.building-dot {
width: 8px;
height: 8px;
background: #4CAF50;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Loading spinner */
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #E0E0E0;
border-top-color: #7B2D8E;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
/* Self-loop styles */
.self-loop-header {
display: flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #E8F5E9 0%, #F1F8E9 100%);
border: 1px solid #C8E6C9;
}
.self-loop-count {
margin-left: auto;
font-size: 11px;
color: #666;
background: rgba(255,255,255,0.8);
padding: 2px 8px;
border-radius: 10px;
}
.self-loop-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.self-loop-item {
background: #FAFAFA;
border: 1px solid #EAEAEA;
border-radius: 8px;
}
.self-loop-item-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #F5F5F5;
cursor: pointer;
transition: background 0.2s;
}
.self-loop-item-header:hover {
background: #EEEEEE;
}
.self-loop-item.expanded .self-loop-item-header {
background: #E8E8E8;
}
.self-loop-index {
font-size: 10px;
font-weight: 600;
color: #888;
background: #E0E0E0;
padding: 2px 6px;
border-radius: 4px;
}
.self-loop-name {
font-size: 12px;
font-weight: 500;
color: #333;
flex: 1;
}
.self-loop-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: #888;
background: #E0E0E0;
border-radius: 4px;
transition: all 0.2s;
}
.self-loop-item.expanded .self-loop-toggle {
background: #D0D0D0;
color: #666;
}
.self-loop-item-content {
padding: 12px;
border-top: 1px solid #EAEAEA;
}
.self-loop-item-content .detail-row {
margin-bottom: 8px;
}
.self-loop-item-content .detail-label {
font-size: 11px;
min-width: 60px;
}
.self-loop-item-content .detail-value {
font-size: 12px;
}
.self-loop-episodes {
margin-top: 8px;
}
.episodes-list.compact {
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
}
.episode-tag.small {
padding: 3px 6px;
font-size: 9px;
}
</style>