|
|
@@ -0,0 +1,713 @@
|
|
|
+<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">
|
|
|
+ <span v-if="derivationStats.edges > 0" class="text-primary">
|
|
|
+ {{ derivationStats.edges }} 条推导边
|
|
|
+ </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 derivationStats = computed(() => {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ if (!postGraph || !postGraph.edges) return { edges: 0, nodes: 0 }
|
|
|
+
|
|
|
+ let edgeCount = 0
|
|
|
+ for (const edge of Object.values(postGraph.edges)) {
|
|
|
+ if (edge.type === '推导' || edge.type === '组成') {
|
|
|
+ edgeCount++
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { edges: edgeCount }
|
|
|
+})
|
|
|
+
|
|
|
+// 提取推导图谱数据
|
|
|
+function extractDerivationData() {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ if (!postGraph) return { nodes: [], links: [] }
|
|
|
+
|
|
|
+ const nodesMap = new Map()
|
|
|
+ const links = []
|
|
|
+
|
|
|
+ for (const edge of Object.values(postGraph.edges)) {
|
|
|
+ if (edge.type !== '推导' && edge.type !== '组成') continue
|
|
|
+
|
|
|
+ if (!nodesMap.has(edge.source)) {
|
|
|
+ const nodeData = postGraph.nodes[edge.source]
|
|
|
+ if (nodeData) {
|
|
|
+ nodesMap.set(edge.source, {
|
|
|
+ id: edge.source,
|
|
|
+ name: nodeData.name,
|
|
|
+ dimension: nodeData.dimension,
|
|
|
+ type: nodeData.type,
|
|
|
+ domain: nodeData.domain || '帖子',
|
|
|
+ ...nodeData
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!nodesMap.has(edge.target)) {
|
|
|
+ const nodeData = postGraph.nodes[edge.target]
|
|
|
+ if (nodeData) {
|
|
|
+ nodesMap.set(edge.target, {
|
|
|
+ id: edge.target,
|
|
|
+ name: nodeData.name,
|
|
|
+ dimension: nodeData.dimension,
|
|
|
+ type: nodeData.type,
|
|
|
+ domain: nodeData.domain || '帖子',
|
|
|
+ ...nodeData
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ links.push({
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: edge.type,
|
|
|
+ score: edge.score,
|
|
|
+ detail: edge.detail
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ nodes: Array.from(nodesMap.values()),
|
|
|
+ links
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 显示锁定按钮
|
|
|
+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 节点处理(使用 store 统一机制)
|
|
|
+function handleNodeHover(event, d) {
|
|
|
+ // 只有在有选中节点时才触发路径高亮
|
|
|
+ if (!selectedNodeId.value) return
|
|
|
+
|
|
|
+ // 不处理选中节点自身
|
|
|
+ if (d.id === selectedNodeId.value) return
|
|
|
+
|
|
|
+ // 计算从当前 hover 节点到激活节点的路径
|
|
|
+ store.computeDerivationPathTo(d.id, selectedNodeId.value, 'derivation')
|
|
|
+
|
|
|
+ // 显示锁定按钮
|
|
|
+ if (store.hoverPathNodes.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) {
|
|
|
+ store.computeDerivationHoverPath(selectedNodeId.value, 'derivation')
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.id)
|
|
|
+}
|
|
|
+
|
|
|
+// 应用选中状态的高亮(只高亮选中节点的入边路径)
|
|
|
+function applySelectedHighlight() {
|
|
|
+ if (!selectedNodeId.value || !nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 计算选中节点的入边路径并存储到本地
|
|
|
+ store.computeDerivationHoverPath(selectedNodeId.value, 'derivation')
|
|
|
+ selectedPathNodes.value = new Set(store.hoverPathNodes)
|
|
|
+ selectedPathEdges.value = new Set(store.hoverPathEdges)
|
|
|
+ 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('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 => {
|
|
|
+ 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(() => 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
|
|
|
+ store.computeDerivationHoverPath(newId, 'derivation')
|
|
|
+ selectedPathNodes.value = new Set(store.hoverPathNodes)
|
|
|
+ selectedPathEdges.value = new Set(store.hoverPathEdges)
|
|
|
+ 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>
|