Spaces:
Sleeping
Sleeping
| <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 || isSimulating" class="graph-building-hint"> | |
| <div class="memory-icon-wrapper"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="memory-icon"> | |
| <path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 4.44-4.04z" /> | |
| <path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-4.44-4.04z" /> | |
| </svg> | |
| </div> | |
| {{ isSimulating ? 'GraphRAG长短期记忆实时更新中' : '实时更新中...' }} | |
| </div> | |
| <!-- 模拟结束后的提示 --> | |
| <div v-if="showSimulationFinishedHint" class="graph-building-hint finished-hint"> | |
| <div class="hint-icon-wrapper"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="hint-icon"> | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <line x1="12" y1="16" x2="12" y2="12"></line> | |
| <line x1="12" y1="8" x2="12.01" y2="8"></line> | |
| </svg> | |
| </div> | |
| <span class="hint-text">还有少量内容处理中,建议稍后手动刷新图谱</span> | |
| <button class="hint-close-btn" @click="dismissFinishedHint" title="关闭提示"> | |
| <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"> | |
| <line x1="18" y1="6" x2="6" y2="18"></line> | |
| <line x1="6" y1="6" x2="18" y2="18"></line> | |
| </svg> | |
| </button> | |
| </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, | |
| isSimulating: Boolean | |
| }) | |
| 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 showSimulationFinishedHint = ref(false) // 模拟结束后的提示 | |
| const wasSimulating = ref(false) // 追踪之前是否在模拟中 | |
| // 关闭模拟结束提示 | |
| const dismissFinishedHint = () => { | |
| showSimulationFinishedHint.value = false | |
| } | |
| // 监听 isSimulating 变化,检测模拟结束 | |
| watch(() => props.isSimulating, (newValue, oldValue) => { | |
| if (wasSimulating.value && !newValue) { | |
| // 从模拟中变为非模拟状态,显示结束提示 | |
| showSimulationFinishedHint.value = true | |
| } | |
| wasSimulating.value = newValue | |
| }, { immediate: true }) | |
| // 切换自环项展开/折叠状态 | |
| 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) => { | |
| // 只记录位置,不重启仿真(区分点击和拖拽) | |
| d.fx = d.x | |
| d.fy = d.y | |
| d._dragStartX = event.x | |
| d._dragStartY = event.y | |
| d._isDragging = false | |
| }) | |
| .on('drag', (event, d) => { | |
| // 检测是否真正开始拖拽(移动超过阈值) | |
| const dx = event.x - d._dragStartX | |
| const dy = event.y - d._dragStartY | |
| const distance = Math.sqrt(dx * dx + dy * dy) | |
| if (!d._isDragging && distance > 3) { | |
| // 首次检测到真正拖拽,才重启仿真 | |
| d._isDragging = true | |
| simulation.alphaTarget(0.3).restart() | |
| } | |
| if (d._isDragging) { | |
| d.fx = event.x | |
| d.fy = event.y | |
| } | |
| }) | |
| .on('end', (event, d) => { | |
| // 只有真正拖拽过才让仿真逐渐停止 | |
| if (d._isDragging) { | |
| simulation.alphaTarget(0) | |
| } | |
| d.fx = null | |
| d.fy = null | |
| d._isDragging = false | |
| }) | |
| ) | |
| .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: 'Noto Sans SC', system-ui, 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: 160px; /* Moved up from 80px */ | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0, 0, 0, 0.65); | |
| backdrop-filter: blur(8px); | |
| color: #fff; | |
| padding: 10px 20px; | |
| border-radius: 30px; | |
| font-size: 13px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| font-weight: 500; | |
| letter-spacing: 0.5px; | |
| z-index: 100; | |
| } | |
| .memory-icon-wrapper { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| animation: breathe 2s ease-in-out infinite; | |
| } | |
| .memory-icon { | |
| width: 18px; | |
| height: 18px; | |
| color: #4CAF50; | |
| } | |
| @keyframes breathe { | |
| 0%, 100% { opacity: 0.7; transform: scale(1); filter: drop-shadow(0 0 2px rgba(76, 175, 80, 0.3)); } | |
| 50% { opacity: 1; transform: scale(1.15); filter: drop-shadow(0 0 8px rgba(76, 175, 80, 0.6)); } | |
| } | |
| /* 模拟结束后的提示样式 */ | |
| .graph-building-hint.finished-hint { | |
| background: rgba(0, 0, 0, 0.65); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .finished-hint .hint-icon-wrapper { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .finished-hint .hint-icon { | |
| width: 18px; | |
| height: 18px; | |
| color: #FFF; | |
| } | |
| .finished-hint .hint-text { | |
| flex: 1; | |
| white-space: nowrap; | |
| } | |
| .hint-close-btn { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 22px; | |
| height: 22px; | |
| background: rgba(255, 255, 255, 0.2); | |
| border: none; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| color: #FFF; | |
| transition: all 0.2s; | |
| margin-left: 8px; | |
| flex-shrink: 0; | |
| } | |
| .hint-close-btn:hover { | |
| background: rgba(255, 255, 255, 0.35); | |
| transform: scale(1.1); | |
| } | |
| /* 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> | |