|
|
@@ -0,0 +1,1080 @@
|
|
|
+<template>
|
|
|
+ <div class="flex flex-col h-full">
|
|
|
+ <!-- 头部 -->
|
|
|
+ <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
|
|
|
+ <span>推导图谱</span>
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
+ <!-- 初始化配置下拉 -->
|
|
|
+ <div class="dropdown dropdown-end">
|
|
|
+ <label tabindex="0" class="btn btn-ghost btn-xs gap-1">
|
|
|
+ <span>构建配置</span>
|
|
|
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
|
+ </svg>
|
|
|
+ </label>
|
|
|
+ <div tabindex="0" class="dropdown-content p-3 shadow bg-base-200 rounded-box w-52 z-50">
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
+ <label class="flex items-center gap-2">
|
|
|
+ <span class="w-20">方向:</span>
|
|
|
+ <select v-model="initConfig.direction" class="select select-xs bg-base-300 flex-1">
|
|
|
+ <option value="backward">反向</option>
|
|
|
+ <option value="forward">正向</option>
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ <label class="flex items-center gap-2">
|
|
|
+ <span class="w-20">Top N:</span>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ v-model.number="initConfig.topN"
|
|
|
+ min="1"
|
|
|
+ max="100"
|
|
|
+ class="input input-xs w-16 text-center bg-base-300"
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ <label class="flex items-center gap-2">
|
|
|
+ <span class="w-20">推导边数:</span>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ v-model.number="initConfig.maxDerivationEdges"
|
|
|
+ min="1"
|
|
|
+ max="10"
|
|
|
+ class="input input-xs w-16 text-center bg-base-300"
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ <div class="flex items-start gap-2">
|
|
|
+ <span class="w-20 shrink-0">起点类型:</span>
|
|
|
+ <div class="flex flex-wrap gap-1">
|
|
|
+ <label v-for="t in nodeTypeOptions" :key="t" class="flex items-center gap-1 cursor-pointer">
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ :value="t"
|
|
|
+ v-model="initConfig.startNodeTypes"
|
|
|
+ class="checkbox checkbox-xs"
|
|
|
+ />
|
|
|
+ <span class="text-xs">{{ t }}</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 点击配置下拉 -->
|
|
|
+ <div class="dropdown dropdown-end">
|
|
|
+ <label tabindex="0" class="btn btn-ghost btn-xs gap-1">
|
|
|
+ <span>点击配置</span>
|
|
|
+ <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
|
+ </svg>
|
|
|
+ </label>
|
|
|
+ <div tabindex="0" class="dropdown-content p-3 shadow bg-base-200 rounded-box w-52 z-50">
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
+ <label class="flex items-center gap-2">
|
|
|
+ <span class="w-20">方向:</span>
|
|
|
+ <select v-model="clickConfig.direction" class="select select-xs bg-base-300 flex-1">
|
|
|
+ <option value="backward">反向</option>
|
|
|
+ <option value="forward">正向</option>
|
|
|
+ </select>
|
|
|
+ </label>
|
|
|
+ <label class="flex items-center gap-2">
|
|
|
+ <span class="w-20">Top N:</span>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ v-model.number="clickConfig.topN"
|
|
|
+ min="1"
|
|
|
+ max="100"
|
|
|
+ class="input input-xs w-16 text-center bg-base-300"
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ <label class="flex items-center gap-2">
|
|
|
+ <span class="w-20">推导边数:</span>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ v-model.number="clickConfig.maxDerivationEdges"
|
|
|
+ min="1"
|
|
|
+ max="10"
|
|
|
+ class="input input-xs w-16 text-center bg-base-300"
|
|
|
+ />
|
|
|
+ </label>
|
|
|
+ <div class="flex items-start gap-2">
|
|
|
+ <span class="w-20 shrink-0">起点类型:</span>
|
|
|
+ <div class="flex flex-wrap gap-1">
|
|
|
+ <label v-for="t in nodeTypeOptions" :key="t" class="flex items-center gap-1 cursor-pointer">
|
|
|
+ <input
|
|
|
+ type="checkbox"
|
|
|
+ :value="t"
|
|
|
+ v-model="clickConfig.startNodeTypes"
|
|
|
+ class="checkbox checkbox-xs"
|
|
|
+ />
|
|
|
+ <span class="text-xs">{{ t }}</span>
|
|
|
+ </label>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <span v-if="derivationStats.edges > 0" class="text-primary">
|
|
|
+ {{ derivationStats.displayedEdges }}/{{ derivationStats.totalEdges }} 条边
|
|
|
+ </span>
|
|
|
+ <span v-else class="text-base-content/40">暂无推导数据</span>
|
|
|
+ <!-- 放大/恢复按钮 -->
|
|
|
+ <button
|
|
|
+ v-if="store.expandedPanel !== 'derivation'"
|
|
|
+ @click="store.expandPanel('derivation')"
|
|
|
+ class="btn btn-ghost btn-xs"
|
|
|
+ title="放大"
|
|
|
+ >⤢</button>
|
|
|
+ <button
|
|
|
+ v-if="store.expandedPanel !== 'default'"
|
|
|
+ @click="store.resetLayout()"
|
|
|
+ class="btn btn-ghost btn-xs"
|
|
|
+ title="恢复"
|
|
|
+ >⊡</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- SVG 容器 -->
|
|
|
+ <div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
|
|
|
+ <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
+import * as d3 from 'd3'
|
|
|
+import { useGraphStore } from '../stores/graph'
|
|
|
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
|
|
|
+import { getEdgeStyle } from '../config/edgeStyle'
|
|
|
+import { applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
|
|
|
+
|
|
|
+const store = useGraphStore()
|
|
|
+
|
|
|
+const containerRef = ref(null)
|
|
|
+const svgRef = ref(null)
|
|
|
+
|
|
|
+let simulation = null
|
|
|
+let mainG = null
|
|
|
+let currentZoom = null
|
|
|
+let nodesData = []
|
|
|
+let linksData = []
|
|
|
+
|
|
|
+// D3 选择集(用于联动)
|
|
|
+let nodeSelection = null
|
|
|
+let linkSelection = null
|
|
|
+let linkLabelSelection = null
|
|
|
+
|
|
|
+// 选中的节点(激活状态)
|
|
|
+const selectedNodeId = ref(null)
|
|
|
+// 选中节点的路径(本地状态,不受其他视图 hover 影响)
|
|
|
+const selectedPathNodes = ref(new Set())
|
|
|
+const selectedPathEdges = ref(new Set())
|
|
|
+
|
|
|
+// ==================== 路径查找配置 ====================
|
|
|
+// 节点类型选项
|
|
|
+const nodeTypeOptions = ['标签', '分类', '点']
|
|
|
+
|
|
|
+// 初始化配置(用于过滤显示的边和节点)
|
|
|
+const initConfig = ref({
|
|
|
+ direction: 'backward', // 从人设节点反向找
|
|
|
+ maxDerivationEdges: 1, // 路径中最多1条推导边
|
|
|
+ topN: 1, // 每个目标节点取前N条路径
|
|
|
+ startNodeTypes: ['标签'] // 起点节点类型(默认标签)
|
|
|
+})
|
|
|
+
|
|
|
+// 点击配置(用于高亮路径)
|
|
|
+const clickConfig = ref({
|
|
|
+ direction: 'backward', // 默认反向找(找入边路径)
|
|
|
+ maxDerivationEdges: 1, // 路径中最多1条推导边
|
|
|
+ topN: 1, // 每个起点取前N条路径
|
|
|
+ startNodeTypes: ['标签'] // 起点节点类型(默认标签)
|
|
|
+})
|
|
|
+
|
|
|
+// ==================== 统一路径查找函数 ====================
|
|
|
+/**
|
|
|
+ * 统一的路径查找函数
|
|
|
+ * @param {Array} edges - 边列表
|
|
|
+ * @param {Object} config - 配置
|
|
|
+ * - startNodes: 起点节点ID列表(可选)
|
|
|
+ * - direction: 'forward' | 'backward'
|
|
|
+ * - maxDerivationEdges: 路径中最大推导边数
|
|
|
+ * @returns {Object} { nodes: Set<nodeId>, edges: Set<edgeKey> }
|
|
|
+ */
|
|
|
+function findPaths(edges, config) {
|
|
|
+ const { startNodes, direction = 'forward', maxDerivationEdges = 1 } = config
|
|
|
+
|
|
|
+ if (!edges || edges.length === 0) return { nodes: new Set(), edges: new Set() }
|
|
|
+
|
|
|
+ // 构建邻接表
|
|
|
+ const adj = new Map()
|
|
|
+ for (const edge of edges) {
|
|
|
+ const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
|
|
|
+ const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
|
|
|
+
|
|
|
+ if (direction === 'forward') {
|
|
|
+ // 正向:source -> target
|
|
|
+ if (!adj.has(srcId)) adj.set(srcId, [])
|
|
|
+ adj.get(srcId).push({ neighbor: tgtId, edge })
|
|
|
+ } else {
|
|
|
+ // 反向:target -> source
|
|
|
+ if (!adj.has(tgtId)) adj.set(tgtId, [])
|
|
|
+ adj.get(tgtId).push({ neighbor: srcId, edge })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // BFS 追踪路径
|
|
|
+ const pathNodes = new Set()
|
|
|
+ const pathEdges = new Set()
|
|
|
+
|
|
|
+ // 起点列表
|
|
|
+ const starts = startNodes || Array.from(adj.keys())
|
|
|
+
|
|
|
+ for (const startId of starts) {
|
|
|
+ // 每个起点独立追踪,记录每个节点到达时已经过的推导边数
|
|
|
+ const queue = [{ nodeId: startId, derivationCount: 0 }]
|
|
|
+ const visited = new Map() // nodeId -> 到达时的最小推导边数
|
|
|
+ visited.set(startId, 0)
|
|
|
+ pathNodes.add(startId)
|
|
|
+
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const { nodeId: curr, derivationCount } = queue.shift()
|
|
|
+
|
|
|
+ for (const { neighbor, edge } of (adj.get(curr) || [])) {
|
|
|
+ const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
|
|
|
+ const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
|
|
|
+ const edgeKey = `${srcId}->${tgtId}`
|
|
|
+
|
|
|
+ // 计算经过这条边后的推导边数
|
|
|
+ const newDerivationCount = derivationCount + (edge.type === '推导' ? 1 : 0)
|
|
|
+
|
|
|
+ // 如果已超过最大推导边数,不添加这条边
|
|
|
+ if (newDerivationCount > maxDerivationEdges) continue
|
|
|
+
|
|
|
+ // 添加边
|
|
|
+ pathEdges.add(edgeKey)
|
|
|
+ pathNodes.add(neighbor)
|
|
|
+
|
|
|
+ // 只有未访问或以更少推导边数到达时才继续追踪
|
|
|
+ const prevCount = visited.get(neighbor)
|
|
|
+ if (prevCount === undefined || newDerivationCount < prevCount) {
|
|
|
+ visited.set(neighbor, newDerivationCount)
|
|
|
+ // 继续追踪(推导边数未超限就可以继续)
|
|
|
+ queue.push({ nodeId: neighbor, derivationCount: newDerivationCount })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { nodes: pathNodes, edges: pathEdges }
|
|
|
+}
|
|
|
+
|
|
|
+// 计算推导统计
|
|
|
+const derivationStats = computed(() => {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ if (!postGraph || !postGraph.edges) return { edges: 0, totalEdges: 0, displayedEdges: 0 }
|
|
|
+
|
|
|
+ // 统计总边数
|
|
|
+ let totalEdges = 0
|
|
|
+ for (const edge of Object.values(postGraph.edges)) {
|
|
|
+ if (edge.type === '推导' || edge.type === '组成') {
|
|
|
+ totalEdges++
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算实际显示的边数(基于 topN 路径)
|
|
|
+ const { links } = extractDerivationData()
|
|
|
+
|
|
|
+ return {
|
|
|
+ edges: links.length,
|
|
|
+ totalEdges,
|
|
|
+ displayedEdges: links.length
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 提取推导图谱数据(对每个目标节点的路径取 topN)
|
|
|
+function extractDerivationData() {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ if (!postGraph) return { nodes: [], links: [] }
|
|
|
+
|
|
|
+ // 1. 收集所有推导边和组成边
|
|
|
+ const allEdges = []
|
|
|
+ const derivationEdges = []
|
|
|
+
|
|
|
+ for (const edge of Object.values(postGraph.edges)) {
|
|
|
+ if (edge.type === '推导') {
|
|
|
+ derivationEdges.push(edge)
|
|
|
+ allEdges.push(edge)
|
|
|
+ } else if (edge.type === '组成') {
|
|
|
+ allEdges.push(edge)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 按目标节点(人设节点)分组推导边,取 topN
|
|
|
+ const targetToEdges = new Map()
|
|
|
+ for (const edge of derivationEdges) {
|
|
|
+ if (!targetToEdges.has(edge.target)) {
|
|
|
+ targetToEdges.set(edge.target, [])
|
|
|
+ }
|
|
|
+ targetToEdges.get(edge.target).push(edge)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 选出的推导边(每个目标节点取 topN)
|
|
|
+ const selectedDerivationEdges = []
|
|
|
+ for (const [targetId, edges] of targetToEdges) {
|
|
|
+ edges.sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
|
+ selectedDerivationEdges.push(...edges.slice(0, initConfig.value.topN))
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 从每个选中的推导边的 source 出发,反向找组成边路径
|
|
|
+ const startNodes = selectedDerivationEdges.map(e => e.source)
|
|
|
+ const compositionEdges = allEdges.filter(e => e.type === '组成')
|
|
|
+
|
|
|
+ const { nodes: compositionNodeIds, edges: compositionEdgeKeys } = findPaths(compositionEdges, {
|
|
|
+ startNodes,
|
|
|
+ direction: 'backward',
|
|
|
+ maxDerivationEdges: 0 // 组成边路径中不应有推导边
|
|
|
+ })
|
|
|
+
|
|
|
+ // 4. 收集所有涉及的节点和边
|
|
|
+ const includedNodes = new Set(compositionNodeIds)
|
|
|
+ const selectedEdges = []
|
|
|
+
|
|
|
+ // 添加推导边涉及的节点
|
|
|
+ for (const edge of selectedDerivationEdges) {
|
|
|
+ includedNodes.add(edge.source)
|
|
|
+ includedNodes.add(edge.target)
|
|
|
+ selectedEdges.push(edge)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 添加组成边
|
|
|
+ for (const edge of compositionEdges) {
|
|
|
+ const edgeKey = `${edge.source}->${edge.target}`
|
|
|
+ if (compositionEdgeKeys.has(edgeKey)) {
|
|
|
+ selectedEdges.push(edge)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5. 构建最终的节点和边数据
|
|
|
+ const nodesMap = new Map()
|
|
|
+ const links = []
|
|
|
+
|
|
|
+ for (const nodeId of includedNodes) {
|
|
|
+ const nodeData = postGraph.nodes[nodeId]
|
|
|
+ if (nodeData) {
|
|
|
+ nodesMap.set(nodeId, {
|
|
|
+ id: nodeId,
|
|
|
+ name: nodeData.name,
|
|
|
+ dimension: nodeData.dimension,
|
|
|
+ type: nodeData.type,
|
|
|
+ domain: nodeData.domain || '帖子',
|
|
|
+ ...nodeData
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const edge of selectedEdges) {
|
|
|
+ links.push({
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: edge.type,
|
|
|
+ score: edge.score,
|
|
|
+ detail: edge.detail
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ nodes: Array.from(nodesMap.values()),
|
|
|
+ links
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 基于当前显示的边计算路径(从 fromId 到 toId)
|
|
|
+function computeLocalPath(fromId, toId) {
|
|
|
+ if (!linksData || linksData.length === 0) return { nodes: new Set(), edges: new Set() }
|
|
|
+
|
|
|
+ // 构建邻接表(双向)
|
|
|
+ const adj = new Map()
|
|
|
+ for (const link of linksData) {
|
|
|
+ const srcId = typeof link.source === 'object' ? link.source.id : link.source
|
|
|
+ const tgtId = typeof link.target === 'object' ? link.target.id : link.target
|
|
|
+
|
|
|
+ if (!adj.has(srcId)) adj.set(srcId, [])
|
|
|
+ if (!adj.has(tgtId)) adj.set(tgtId, [])
|
|
|
+ adj.get(srcId).push({ neighbor: tgtId, edge: link })
|
|
|
+ adj.get(tgtId).push({ neighbor: srcId, edge: link })
|
|
|
+ }
|
|
|
+
|
|
|
+ // BFS 找路径
|
|
|
+ const visited = new Set([fromId])
|
|
|
+ const parent = new Map()
|
|
|
+ const parentEdge = new Map()
|
|
|
+ const queue = [fromId]
|
|
|
+
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const curr = queue.shift()
|
|
|
+ if (curr === toId) break
|
|
|
+
|
|
|
+ for (const { neighbor, edge } of (adj.get(curr) || [])) {
|
|
|
+ if (!visited.has(neighbor)) {
|
|
|
+ visited.add(neighbor)
|
|
|
+ parent.set(neighbor, curr)
|
|
|
+ parentEdge.set(neighbor, edge)
|
|
|
+ queue.push(neighbor)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 回溯路径
|
|
|
+ const pathNodes = new Set()
|
|
|
+ const pathEdges = new Set()
|
|
|
+
|
|
|
+ if (visited.has(toId)) {
|
|
|
+ let curr = toId
|
|
|
+ while (curr) {
|
|
|
+ pathNodes.add(curr)
|
|
|
+ const edge = parentEdge.get(curr)
|
|
|
+ if (edge) {
|
|
|
+ const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
|
|
|
+ const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
|
|
|
+ pathEdges.add(`${srcId}->${tgtId}`)
|
|
|
+ pathEdges.add(`${tgtId}->${srcId}`) // 双向
|
|
|
+ }
|
|
|
+ curr = parent.get(curr)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { nodes: pathNodes, edges: pathEdges }
|
|
|
+}
|
|
|
+
|
|
|
+// 基于当前显示的边计算从某节点出发的路径(使用统一的 findPaths)
|
|
|
+function computeLocalHoverPath(nodeId) {
|
|
|
+ if (!linksData || linksData.length === 0) return { nodes: new Set(), edges: new Set() }
|
|
|
+
|
|
|
+ // 使用统一的 findPaths 函数,配置来自 clickConfig
|
|
|
+ return findPaths(linksData, {
|
|
|
+ startNodes: [nodeId],
|
|
|
+ direction: clickConfig.value.direction,
|
|
|
+ maxDerivationEdges: clickConfig.value.maxDerivationEdges
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 显示锁定按钮
|
|
|
+function showLockButton(nodeEl, isLocked = false) {
|
|
|
+ if (!nodeEl) return
|
|
|
+
|
|
|
+ const node = d3.select(nodeEl)
|
|
|
+ const textEl = node.select('text')
|
|
|
+ if (textEl.empty()) return
|
|
|
+
|
|
|
+ const nodeData = node.datum()
|
|
|
+ const currentNodeId = nodeData?.id
|
|
|
+ const isThisNodeLocked = store.lockedHoverNodeId === currentNodeId
|
|
|
+
|
|
|
+ let btn = textEl.select('.lock-btn')
|
|
|
+ if (!btn.empty()) {
|
|
|
+ btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
|
|
|
+ .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
|
|
|
+ if (!isThisNodeLocked) startBreathingAnimation(btn)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清除其他按钮
|
|
|
+ if (svgRef.value) {
|
|
|
+ d3.select(svgRef.value).selectAll('.lock-btn').remove()
|
|
|
+ }
|
|
|
+
|
|
|
+ const newBtn = textEl.append('tspan')
|
|
|
+ .attr('class', 'lock-btn')
|
|
|
+ .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
|
|
|
+ .attr('font-weight', 'bold')
|
|
|
+ .style('cursor', 'pointer')
|
|
|
+ .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
|
|
|
+ .on('click', (e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ handleLockClick()
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!isThisNodeLocked) {
|
|
|
+ startBreathingAnimation(newBtn)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 呼吸动画
|
|
|
+function startBreathingAnimation(btn) {
|
|
|
+ function breathe() {
|
|
|
+ if (btn.empty() || !btn.node()) return
|
|
|
+ if (store.lockedHoverNodeId) return
|
|
|
+ btn
|
|
|
+ .transition()
|
|
|
+ .duration(800)
|
|
|
+ .attr('fill', '#90cdf4')
|
|
|
+ .transition()
|
|
|
+ .duration(800)
|
|
|
+ .attr('fill', '#63b3ed')
|
|
|
+ .on('end', breathe)
|
|
|
+ }
|
|
|
+ breathe()
|
|
|
+}
|
|
|
+
|
|
|
+// 隐藏锁定按钮
|
|
|
+function hideLockButton() {
|
|
|
+ if (svgRef.value) {
|
|
|
+ d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 抖动按钮
|
|
|
+function shakeLockButton() {
|
|
|
+ d3.selectAll('.lock-btn')
|
|
|
+ .interrupt()
|
|
|
+ .attr('fill', '#fc8181')
|
|
|
+ .transition().duration(50).attr('dx', 3)
|
|
|
+ .transition().duration(50).attr('dx', -3)
|
|
|
+ .transition().duration(50).attr('dx', 3)
|
|
|
+ .transition().duration(50).attr('dx', -3)
|
|
|
+ .transition().duration(50).attr('dx', 0)
|
|
|
+ .transition().duration(200).attr('fill', '#f6ad55')
|
|
|
+}
|
|
|
+
|
|
|
+// 处理锁定按钮点击
|
|
|
+function handleLockClick() {
|
|
|
+ const currentHoverNodeId = store.hoverNodeId
|
|
|
+
|
|
|
+ if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
|
|
|
+ // 解锁
|
|
|
+ store.clearLockedHover()
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ d3.selectAll('.lock-btn')
|
|
|
+ .interrupt()
|
|
|
+ .text(' 🔓解锁')
|
|
|
+ .attr('fill', '#f6ad55')
|
|
|
+ } else {
|
|
|
+ d3.selectAll('.lock-btn').interrupt().remove()
|
|
|
+ }
|
|
|
+ } else if (currentHoverNodeId) {
|
|
|
+ // 锁定
|
|
|
+ store.lockCurrentHover()
|
|
|
+ d3.selectAll('.lock-btn')
|
|
|
+ .interrupt()
|
|
|
+ .text(' 🔓解锁')
|
|
|
+ .attr('fill', '#f6ad55')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// hover 节点处理(使用本地路径计算,基于过滤后的边)
|
|
|
+function handleNodeHover(event, d) {
|
|
|
+ // 只有在有选中节点时才触发路径高亮
|
|
|
+ if (!selectedNodeId.value) return
|
|
|
+
|
|
|
+ // 不处理选中节点自身
|
|
|
+ if (d.id === selectedNodeId.value) return
|
|
|
+
|
|
|
+ // 使用本地路径计算(基于当前显示的边)
|
|
|
+ const { nodes: pathNodes, edges: pathEdges } = computeLocalPath(d.id, selectedNodeId.value)
|
|
|
+
|
|
|
+ // 更新 store 状态用于联动
|
|
|
+ store.hoverNodeId = d.id
|
|
|
+ store.hoverPathNodes = pathNodes
|
|
|
+ store.hoverPathEdges = pathEdges
|
|
|
+ store.hoverSource = 'derivation'
|
|
|
+ store.setHoverNode(d)
|
|
|
+
|
|
|
+ // 应用高亮
|
|
|
+ applyDerivationHighlight()
|
|
|
+
|
|
|
+ // 显示锁定按钮
|
|
|
+ if (pathNodes.size > 0) {
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ nodeSelection.each(function(nd) {
|
|
|
+ if (nd.id === store.lockedHoverNodeId) {
|
|
|
+ showLockButton(this, true)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ showLockButton(this)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// hover 离开处理
|
|
|
+function handleNodeHoverOut() {
|
|
|
+ // 只有在有选中节点时才处理
|
|
|
+ if (!selectedNodeId.value) return
|
|
|
+
|
|
|
+ store.clearHover()
|
|
|
+
|
|
|
+ // 恢复到选中节点的路径高亮(使用本地路径计算)
|
|
|
+ if (selectedNodeId.value && !store.lockedHoverNodeId) {
|
|
|
+ const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
|
|
|
+ store.hoverPathNodes = pathNodes
|
|
|
+ store.hoverPathEdges = pathEdges
|
|
|
+ store.hoverSource = 'derivation'
|
|
|
+ applyDerivationHighlight()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ // 恢复锁定状态,在锁定节点上显示按钮
|
|
|
+ nodeSelection.each(function(d) {
|
|
|
+ if (d.id === store.lockedHoverNodeId) {
|
|
|
+ showLockButton(this, true)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ hideLockButton()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 点击节点处理(联动 store)
|
|
|
+function handleNodeClick(event, d) {
|
|
|
+ event.stopPropagation()
|
|
|
+
|
|
|
+ // 锁定状态下点击节点提醒
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ shakeLockButton()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置/切换选中状态
|
|
|
+ if (selectedNodeId.value === d.id) {
|
|
|
+ // 再次点击取消选中
|
|
|
+ selectedNodeId.value = null
|
|
|
+ clearHighlightState()
|
|
|
+ } else {
|
|
|
+ // 选中新节点
|
|
|
+ selectedNodeId.value = d.id
|
|
|
+ applySelectedHighlight()
|
|
|
+ }
|
|
|
+
|
|
|
+ // 联动 store(传完整节点对象,以便详情面板显示入边/出边)
|
|
|
+ store.selectNode(d)
|
|
|
+}
|
|
|
+
|
|
|
+// 应用选中状态的高亮(只高亮选中节点的入边路径,基于当前显示的边)
|
|
|
+function applySelectedHighlight() {
|
|
|
+ if (!selectedNodeId.value || !nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 使用本地路径计算(基于当前显示的边)
|
|
|
+ const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(selectedNodeId.value)
|
|
|
+ selectedPathNodes.value = pathNodes
|
|
|
+ selectedPathEdges.value = pathEdges
|
|
|
+
|
|
|
+ // 更新 store 状态
|
|
|
+ store.hoverPathNodes = pathNodes
|
|
|
+ store.hoverPathEdges = pathEdges
|
|
|
+ store.hoverSource = 'derivation'
|
|
|
+ applyDerivationHighlight()
|
|
|
+}
|
|
|
+
|
|
|
+// 清除高亮状态
|
|
|
+function clearHighlightState() {
|
|
|
+ selectedPathNodes.value = new Set()
|
|
|
+ selectedPathEdges.value = new Set()
|
|
|
+ store.clearHover()
|
|
|
+ store.clearAllLocked()
|
|
|
+ if (nodeSelection && linkSelection) {
|
|
|
+ clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
|
|
|
+ // 恢复箭头(根据边类型)
|
|
|
+ linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
|
|
|
+ }
|
|
|
+ hideLockButton()
|
|
|
+}
|
|
|
+
|
|
|
+// 点击空白处
|
|
|
+function handleSvgClick(event) {
|
|
|
+ // 锁定状态下点击空白无效
|
|
|
+ if (store.lockedHoverNodeId) return
|
|
|
+
|
|
|
+ if (event.target === svgRef.value) {
|
|
|
+ selectedNodeId.value = null
|
|
|
+ clearHighlightState()
|
|
|
+ store.clearSelection()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 应用 hover 高亮效果(基于推导图谱的边)
|
|
|
+function applyDerivationHighlight() {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 优先使用 store 的 hover 路径(推导图谱内部 hover),否则使用本地状态
|
|
|
+ const storePathNodes = store.hoverSource === 'derivation' ? store.hoverPathNodes : new Set()
|
|
|
+ const storePathEdges = store.hoverSource === 'derivation' ? store.hoverPathEdges : new Set()
|
|
|
+ const pathNodes = storePathNodes.size > 0 ? storePathNodes : selectedPathNodes.value
|
|
|
+ const pathEdges = storePathEdges.size > 0 ? storePathEdges : selectedPathEdges.value
|
|
|
+ const lockedPathNodes = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
|
|
|
+ const hasHighlight = pathNodes.size > 0
|
|
|
+
|
|
|
+ if (!hasHighlight) {
|
|
|
+ clearHoverHighlight(nodeSelection, linkSelection, linkLabelSelection)
|
|
|
+ // 恢复箭头(根据边类型)
|
|
|
+ linkSelection.attr('marker-end', d => `url(#arrow-${d.type})`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 合并路径节点
|
|
|
+ const allPathNodes = new Set([...pathNodes])
|
|
|
+ if (lockedPathNodes) {
|
|
|
+ for (const id of lockedPathNodes) {
|
|
|
+ allPathNodes.add(id)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 节点高亮
|
|
|
+ nodeSelection
|
|
|
+ .classed('dimmed', d => !allPathNodes.has(d.id))
|
|
|
+ .classed('locked-path', d => lockedPathNodes && lockedPathNodes.has(d.id) && !pathNodes.has(d.id))
|
|
|
+ .classed('selected', d => d.id === store.hoverNodeId)
|
|
|
+
|
|
|
+ // 边高亮(使用 pathEdges 判断,而不是节点)
|
|
|
+ linkSelection.each(function(d) {
|
|
|
+ const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
|
|
+ const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
|
|
+ const edgeKey = `${srcId}->${tgtId}`
|
|
|
+
|
|
|
+ const inPath = pathEdges.has(edgeKey)
|
|
|
+ // TODO: 如果需要支持锁定路径的边,也需要存储 lockedPathEdges
|
|
|
+ const isLockedPath = false
|
|
|
+
|
|
|
+ d3.select(this)
|
|
|
+ .classed('dimmed', !inPath)
|
|
|
+ .classed('locked-path', isLockedPath)
|
|
|
+ .classed('highlighted', inPath)
|
|
|
+
|
|
|
+ // 切换箭头(根据边类型选择对应颜色)
|
|
|
+ const edgeType = d.type
|
|
|
+ let markerUrl = `url(#arrow-${edgeType})`
|
|
|
+ if (!inPath) {
|
|
|
+ markerUrl = `url(#arrow-${edgeType}-dimmed)`
|
|
|
+ } else if (isLockedPath) {
|
|
|
+ markerUrl = `url(#arrow-${edgeType}-locked)`
|
|
|
+ }
|
|
|
+ d3.select(this).attr('marker-end', markerUrl)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 标签高亮
|
|
|
+ if (linkLabelSelection) {
|
|
|
+ linkLabelSelection.each(function(d) {
|
|
|
+ const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
|
|
+ const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
|
|
+ const edgeKey = `${srcId}->${tgtId}`
|
|
|
+
|
|
|
+ const inPath = pathEdges.has(edgeKey)
|
|
|
+
|
|
|
+ d3.select(this)
|
|
|
+ .classed('dimmed', !inPath)
|
|
|
+ .classed('locked-path', false)
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 渲染力导向图
|
|
|
+function render() {
|
|
|
+ if (!svgRef.value || !containerRef.value) return
|
|
|
+
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ svg.selectAll('*').remove()
|
|
|
+
|
|
|
+ const { nodes, links } = extractDerivationData()
|
|
|
+ nodesData = nodes
|
|
|
+ linksData = links
|
|
|
+
|
|
|
+ if (nodes.length === 0) return
|
|
|
+
|
|
|
+ const width = containerRef.value.clientWidth
|
|
|
+ const height = containerRef.value.clientHeight
|
|
|
+
|
|
|
+ // 定义箭头标记(为每种边类型创建对应颜色的箭头)
|
|
|
+ const defs = svg.append('defs')
|
|
|
+ const arrowColors = {
|
|
|
+ '推导': '#00bcd4',
|
|
|
+ '组成': '#8bc34a'
|
|
|
+ }
|
|
|
+
|
|
|
+ // 为每种边类型创建正常、置灰、锁定三种状态的箭头
|
|
|
+ for (const [type, color] of Object.entries(arrowColors)) {
|
|
|
+ // 正常箭头
|
|
|
+ defs.append('marker')
|
|
|
+ .attr('id', `arrow-${type}`)
|
|
|
+ .attr('viewBox', '0 -2 4 4')
|
|
|
+ .attr('refX', 10)
|
|
|
+ .attr('refY', 0)
|
|
|
+ .attr('markerWidth', 3)
|
|
|
+ .attr('markerHeight', 3)
|
|
|
+ .attr('orient', 'auto')
|
|
|
+ .append('path')
|
|
|
+ .attr('d', 'M0,-2L4,0L0,2')
|
|
|
+ .attr('fill', color)
|
|
|
+
|
|
|
+ // 置灰箭头
|
|
|
+ defs.append('marker')
|
|
|
+ .attr('id', `arrow-${type}-dimmed`)
|
|
|
+ .attr('viewBox', '0 -2 4 4')
|
|
|
+ .attr('refX', 10)
|
|
|
+ .attr('refY', 0)
|
|
|
+ .attr('markerWidth', 3)
|
|
|
+ .attr('markerHeight', 3)
|
|
|
+ .attr('orient', 'auto')
|
|
|
+ .append('path')
|
|
|
+ .attr('d', 'M0,-2L4,0L0,2')
|
|
|
+ .attr('fill', color)
|
|
|
+ .attr('fill-opacity', 0.1)
|
|
|
+
|
|
|
+ // 锁定路径箭头
|
|
|
+ defs.append('marker')
|
|
|
+ .attr('id', `arrow-${type}-locked`)
|
|
|
+ .attr('viewBox', '0 -2 4 4')
|
|
|
+ .attr('refX', 10)
|
|
|
+ .attr('refY', 0)
|
|
|
+ .attr('markerWidth', 3)
|
|
|
+ .attr('markerHeight', 3)
|
|
|
+ .attr('orient', 'auto')
|
|
|
+ .append('path')
|
|
|
+ .attr('d', 'M0,-2L4,0L0,2')
|
|
|
+ .attr('fill', color)
|
|
|
+ .attr('fill-opacity', 0.4)
|
|
|
+ }
|
|
|
+
|
|
|
+ mainG = svg.append('g')
|
|
|
+
|
|
|
+ currentZoom = d3.zoom()
|
|
|
+ .scaleExtent([0.1, 4])
|
|
|
+ .on('zoom', (event) => {
|
|
|
+ mainG.attr('transform', event.transform)
|
|
|
+ })
|
|
|
+
|
|
|
+ svg.call(currentZoom)
|
|
|
+
|
|
|
+ simulation = d3.forceSimulation(nodes)
|
|
|
+ .force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
|
|
+ .force('charge', d3.forceManyBody().strength(-200))
|
|
|
+ .force('center', d3.forceCenter(width / 2, height / 2))
|
|
|
+ .force('collision', d3.forceCollide().radius(30))
|
|
|
+
|
|
|
+ // 绘制边
|
|
|
+ linkSelection = mainG.append('g')
|
|
|
+ .attr('class', 'links')
|
|
|
+ .selectAll('line')
|
|
|
+ .data(links)
|
|
|
+ .join('line')
|
|
|
+ .attr('class', 'graph-link')
|
|
|
+ .attr('stroke', d => getEdgeStyle(d).color)
|
|
|
+ .attr('stroke-opacity', d => getEdgeStyle(d).opacity)
|
|
|
+ .attr('stroke-width', d => getEdgeStyle(d).strokeWidth)
|
|
|
+ .attr('stroke-dasharray', d => getEdgeStyle(d).strokeDasharray)
|
|
|
+ .attr('marker-end', d => `url(#arrow-${d.type})`)
|
|
|
+ .style('cursor', 'pointer')
|
|
|
+ .on('mouseenter', (e, d) => {
|
|
|
+ // 激活状态下 hover 边,显示边详情
|
|
|
+ if (selectedNodeId.value) {
|
|
|
+ store.setHoverEdge(d)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .on('mouseleave', () => {
|
|
|
+ store.clearHoverEdge()
|
|
|
+ })
|
|
|
+ .on('click', (e, d) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ store.selectEdge(d)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 边标签
|
|
|
+ linkLabelSelection = mainG.append('g')
|
|
|
+ .attr('class', 'link-labels')
|
|
|
+ .selectAll('g')
|
|
|
+ .data(links.filter(d => d.score > 0))
|
|
|
+ .join('g')
|
|
|
+ .attr('class', 'graph-link-label')
|
|
|
+
|
|
|
+ linkLabelSelection.append('rect')
|
|
|
+ .attr('x', -14)
|
|
|
+ .attr('y', -6)
|
|
|
+ .attr('width', 28)
|
|
|
+ .attr('height', 12)
|
|
|
+ .attr('rx', 2)
|
|
|
+ .attr('fill', '#1d232a')
|
|
|
+ .attr('opacity', 0.9)
|
|
|
+
|
|
|
+ linkLabelSelection.append('text')
|
|
|
+ .attr('text-anchor', 'middle')
|
|
|
+ .attr('dy', '0.35em')
|
|
|
+ .attr('fill', d => getEdgeStyle(d).color)
|
|
|
+ .attr('font-size', '8px')
|
|
|
+ .text(d => d.score.toFixed(2))
|
|
|
+
|
|
|
+ // 绘制节点
|
|
|
+ nodeSelection = mainG.append('g')
|
|
|
+ .attr('class', 'nodes')
|
|
|
+ .selectAll('g')
|
|
|
+ .data(nodes)
|
|
|
+ .join('g')
|
|
|
+ .attr('class', 'graph-node')
|
|
|
+ .style('cursor', 'pointer')
|
|
|
+ .on('mouseenter', handleNodeHover)
|
|
|
+ .on('mouseleave', handleNodeHoverOut)
|
|
|
+ .on('click', handleNodeClick)
|
|
|
+ .call(d3.drag()
|
|
|
+ .on('start', dragstarted)
|
|
|
+ .on('drag', dragged)
|
|
|
+ .on('end', dragended))
|
|
|
+
|
|
|
+ // 节点形状
|
|
|
+ nodeSelection.each(function(d) {
|
|
|
+ const style = getNodeStyle(d)
|
|
|
+ applyNodeShape(d3.select(this), style)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 节点标签(组合节点不显示名称)
|
|
|
+ nodeSelection.append('text')
|
|
|
+ .attr('dy', 20)
|
|
|
+ .attr('text-anchor', 'middle')
|
|
|
+ .text(d => {
|
|
|
+ // 组合节点(type为"组合")不显示名称
|
|
|
+ if (d.type === '组合') return ''
|
|
|
+ const name = d.name || ''
|
|
|
+ return name.length > 8 ? name.slice(0, 8) + '…' : name
|
|
|
+ })
|
|
|
+
|
|
|
+ // 更新位置
|
|
|
+ simulation.on('tick', () => {
|
|
|
+ linkSelection
|
|
|
+ .attr('x1', d => d.source.x)
|
|
|
+ .attr('y1', d => d.source.y)
|
|
|
+ .attr('x2', d => d.target.x)
|
|
|
+ .attr('y2', d => d.target.y)
|
|
|
+
|
|
|
+ linkLabelSelection.attr('transform', d => {
|
|
|
+ const x = (d.source.x + d.target.x) / 2
|
|
|
+ const y = (d.source.y + d.target.y) / 2 - 10
|
|
|
+ return `translate(${x},${y})`
|
|
|
+ })
|
|
|
+
|
|
|
+ nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
|
|
+ })
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ setTimeout(() => fitToView(), 500)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 适应视图
|
|
|
+function fitToView() {
|
|
|
+ if (!mainG || !svgRef.value || !containerRef.value) return
|
|
|
+
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ const width = containerRef.value.clientWidth
|
|
|
+ const height = containerRef.value.clientHeight
|
|
|
+
|
|
|
+ try {
|
|
|
+ const bounds = mainG.node().getBBox()
|
|
|
+ if (bounds.width === 0 || bounds.height === 0) return
|
|
|
+
|
|
|
+ const scale = Math.min(
|
|
|
+ width / (bounds.width + 80),
|
|
|
+ height / (bounds.height + 80),
|
|
|
+ 1.5
|
|
|
+ )
|
|
|
+ const tx = (width - bounds.width * scale) / 2 - bounds.x * scale
|
|
|
+ const ty = (height - bounds.height * scale) / 2 - bounds.y * scale
|
|
|
+ svg.call(currentZoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale))
|
|
|
+ } catch (e) {
|
|
|
+ // getBBox 可能在元素不可见时失败
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 拖拽函数
|
|
|
+function dragstarted(event, d) {
|
|
|
+ if (!event.active) simulation.alphaTarget(0.3).restart()
|
|
|
+ d.fx = d.x
|
|
|
+ d.fy = d.y
|
|
|
+}
|
|
|
+
|
|
|
+function dragged(event, d) {
|
|
|
+ d.fx = event.x
|
|
|
+ d.fy = event.y
|
|
|
+}
|
|
|
+
|
|
|
+function dragended(event, d) {
|
|
|
+ if (!event.active) simulation.alphaTarget(0)
|
|
|
+ d.fx = null
|
|
|
+ d.fy = null
|
|
|
+}
|
|
|
+
|
|
|
+// 监听帖子切换
|
|
|
+watch(() => store.currentPostGraph, () => {
|
|
|
+ nextTick(() => render())
|
|
|
+}, { immediate: true })
|
|
|
+
|
|
|
+// 监听初始化配置变化,重新渲染
|
|
|
+watch(initConfig, () => {
|
|
|
+ nextTick(() => render())
|
|
|
+}, { deep: true })
|
|
|
+
|
|
|
+// 监听面板展开状态变化
|
|
|
+watch(() => store.expandedPanel, () => {
|
|
|
+ nextTick(() => {
|
|
|
+ if (containerRef.value) {
|
|
|
+ setTimeout(() => fitToView(), 300)
|
|
|
+ }
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+// 监听 hover 状态变化(联动)
|
|
|
+watch([() => store.hoverPathNodes.size, () => store.hoverNodeId, () => store.hoverSource], () => {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 只处理来自推导图谱的 hover
|
|
|
+ if (store.hoverSource === 'derivation') {
|
|
|
+ applyDerivationHighlight()
|
|
|
+ } else if (store.hoverSource !== 'derivation' && selectedNodeId.value) {
|
|
|
+ // 其他视图的 hover 不影响推导图谱的激活状态,保持当前激活节点的高亮
|
|
|
+ // 不做任何处理,保持现有高亮
|
|
|
+ } else if (store.hoverPathNodes.size === 0 && !selectedNodeId.value) {
|
|
|
+ // 没有激活节点且 hover 清空时,清除高亮
|
|
|
+ applyDerivationHighlight()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 监听外部节点选中(联动:PostTreeView 点击节点时同步激活推导图谱)
|
|
|
+watch(() => store.selectedNodeId, (newId) => {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 检查该节点是否在推导图谱中
|
|
|
+ const nodeInGraph = nodesData.find(n => n.id === newId)
|
|
|
+
|
|
|
+ if (nodeInGraph) {
|
|
|
+ // 节点在推导图谱中,激活并显示高亮(使用本地路径计算)
|
|
|
+ selectedNodeId.value = newId
|
|
|
+ const { nodes: pathNodes, edges: pathEdges } = computeLocalHoverPath(newId)
|
|
|
+ selectedPathNodes.value = pathNodes
|
|
|
+ selectedPathEdges.value = pathEdges
|
|
|
+ store.hoverPathNodes = pathNodes
|
|
|
+ store.hoverPathEdges = pathEdges
|
|
|
+ store.hoverSource = 'derivation'
|
|
|
+ applyDerivationHighlight()
|
|
|
+ } else {
|
|
|
+ // 节点不在推导图谱中,清除选中状态
|
|
|
+ selectedNodeId.value = null
|
|
|
+ selectedPathNodes.value = new Set()
|
|
|
+ selectedPathEdges.value = new Set()
|
|
|
+ clearHighlightState()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+// 窗口大小变化时适应视图
|
|
|
+let resizeObserver = null
|
|
|
+onMounted(() => {
|
|
|
+ resizeObserver = new ResizeObserver(() => {
|
|
|
+ if (simulation) {
|
|
|
+ fitToView()
|
|
|
+ }
|
|
|
+ })
|
|
|
+ if (containerRef.value) {
|
|
|
+ resizeObserver.observe(containerRef.value)
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (simulation) simulation.stop()
|
|
|
+ if (resizeObserver) resizeObserver.disconnect()
|
|
|
+})
|
|
|
+</script>
|