|
|
@@ -0,0 +1,479 @@
|
|
|
+<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-3">
|
|
|
+ <span class="flex items-center gap-1">
|
|
|
+ <span class="w-3 h-0.5" :style="{ backgroundColor: edgeTypeColors['支撑'] }"></span>
|
|
|
+ <span>支撑 {{ supportCount }}</span>
|
|
|
+ </span>
|
|
|
+ <span class="flex items-center gap-1">
|
|
|
+ <span class="w-3 h-0.5 border-t border-dashed" :style="{ borderColor: edgeTypeColors['关联'] }"></span>
|
|
|
+ <span>关联 {{ relationCount }}</span>
|
|
|
+ </span>
|
|
|
+ </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, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
+import * as d3 from 'd3'
|
|
|
+import { useGraphStore } from '../stores/graph'
|
|
|
+import { getNodeStyle, applyNodeShape } from '../config/nodeStyle'
|
|
|
+import { edgeTypeColors, getEdgeStyle, createArrowMarkers } from '../config/edgeStyle'
|
|
|
+import { applyHoverHighlight as applyHoverHighlightUtil, applyEdgeHoverHighlight as applyEdgeHoverHighlightUtil, clearHoverHighlight } from '../utils/highlight'
|
|
|
+
|
|
|
+const store = useGraphStore()
|
|
|
+const containerRef = ref(null)
|
|
|
+const svgRef = ref(null)
|
|
|
+
|
|
|
+let simulation = null
|
|
|
+let currentZoom = null
|
|
|
+let mainG = null
|
|
|
+let nodeSelection = null
|
|
|
+let linkSelection = null
|
|
|
+
|
|
|
+// 提取标签节点和关系边
|
|
|
+const graphData = computed(() => {
|
|
|
+ const postGraph = store.currentPostGraph
|
|
|
+ if (!postGraph || !postGraph.nodes || !postGraph.edges) {
|
|
|
+ return { nodes: [], edges: [] }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 提取标签节点
|
|
|
+ const tagNodes = []
|
|
|
+ for (const [id, node] of Object.entries(postGraph.nodes)) {
|
|
|
+ if (node.type === '标签') {
|
|
|
+ tagNodes.push({
|
|
|
+ id,
|
|
|
+ name: node.name,
|
|
|
+ type: node.type,
|
|
|
+ dimension: node.dimension,
|
|
|
+ domain: node.domain || '帖子'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 提取支撑边和关联边
|
|
|
+ const relationEdges = []
|
|
|
+ for (const edge of Object.values(postGraph.edges)) {
|
|
|
+ if (edge.type === '支撑' || edge.type === '关联') {
|
|
|
+ relationEdges.push({
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: edge.type,
|
|
|
+ score: edge.score || 1.0
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { nodes: tagNodes, edges: relationEdges }
|
|
|
+})
|
|
|
+
|
|
|
+// 统计
|
|
|
+const supportCount = computed(() =>
|
|
|
+ graphData.value.edges.filter(e => e.type === '支撑').length
|
|
|
+)
|
|
|
+const relationCount = computed(() =>
|
|
|
+ graphData.value.edges.filter(e => e.type === '关联').length
|
|
|
+)
|
|
|
+
|
|
|
+// ==================== 事件处理 ====================
|
|
|
+
|
|
|
+// 节点 hover
|
|
|
+function handleNodeHover(event, d) {
|
|
|
+ // 非激活状态:只显示节点详情,不设置全局状态避免影响其他视图
|
|
|
+ if (!store.selectedNodeId) {
|
|
|
+ store.setHoverNode(d) // 只显示详情面板
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ // 激活状态:收集相关节点和边,高亮路径
|
|
|
+ store.hoverNodeId = d.id
|
|
|
+ store.hoverSource = 'relation'
|
|
|
+ store.setHoverNode(d)
|
|
|
+
|
|
|
+ // 收集相关节点和边
|
|
|
+ const pathNodes = new Set([d.id])
|
|
|
+ const pathEdges = new Set()
|
|
|
+
|
|
|
+ const { edges } = graphData.value
|
|
|
+ edges.forEach(edge => {
|
|
|
+ if (edge.source.id === d.id || edge.source === d.id) {
|
|
|
+ pathNodes.add(typeof edge.target === 'object' ? edge.target.id : edge.target)
|
|
|
+ pathEdges.add(`${edge.source.id || edge.source}|${edge.type}|${edge.target.id || edge.target}`)
|
|
|
+ }
|
|
|
+ if (edge.target.id === d.id || edge.target === d.id) {
|
|
|
+ pathNodes.add(typeof edge.source === 'object' ? edge.source.id : edge.source)
|
|
|
+ pathEdges.add(`${edge.source.id || edge.source}|${edge.type}|${edge.target.id || edge.target}`)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ store.hoverPathNodes = pathNodes
|
|
|
+ store.hoverPathEdges = pathEdges
|
|
|
+ applyHoverHighlight()
|
|
|
+}
|
|
|
+
|
|
|
+// 节点 hover 离开
|
|
|
+function handleNodeHoverOut() {
|
|
|
+ if (store.lockedHoverNodeId) return
|
|
|
+
|
|
|
+ // 清除详情
|
|
|
+ store.clearHoverNode()
|
|
|
+
|
|
|
+ // 只在激活状态下才清除全局状态
|
|
|
+ if (store.selectedNodeId) {
|
|
|
+ store.hoverNodeId = null
|
|
|
+ store.hoverPathNodes = new Set()
|
|
|
+ store.hoverPathEdges = new Set()
|
|
|
+ store.hoverSource = null
|
|
|
+ clearHighlight()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 节点点击(激活节点,高亮相关路径,同步到其他视图)
|
|
|
+function handleNodeClick(event, d) {
|
|
|
+ event.stopPropagation()
|
|
|
+
|
|
|
+ // 使用 store.selectNode 统一处理,会同步到其他视图
|
|
|
+ store.selectNode(d)
|
|
|
+
|
|
|
+ // 本地高亮:收集点击节点的相关边和节点
|
|
|
+ const pathNodes = new Set([d.id])
|
|
|
+ const pathEdges = new Set()
|
|
|
+
|
|
|
+ const { edges } = graphData.value
|
|
|
+ edges.forEach(edge => {
|
|
|
+ const srcId = typeof edge.source === 'object' ? edge.source.id : edge.source
|
|
|
+ const tgtId = typeof edge.target === 'object' ? edge.target.id : edge.target
|
|
|
+ if (srcId === d.id || tgtId === d.id) {
|
|
|
+ pathNodes.add(srcId)
|
|
|
+ pathNodes.add(tgtId)
|
|
|
+ pathEdges.add(`${srcId}|${edge.type}|${tgtId}`)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 应用本地高亮
|
|
|
+ applyClickHighlight(d.id, pathNodes, pathEdges)
|
|
|
+}
|
|
|
+
|
|
|
+// SVG 空白区域点击
|
|
|
+function handleSvgClick(event) {
|
|
|
+ if (event.target === svgRef.value) {
|
|
|
+ store.clearSelection()
|
|
|
+ clearHighlight()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 边 hover(本地高亮,不影响其他视图)
|
|
|
+function handleEdgeHover(event, d) {
|
|
|
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source
|
|
|
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
|
|
+ const edgeId = `${sourceId}|${d.type}|${targetId}`
|
|
|
+
|
|
|
+ // 只设置边详情用于显示,不设置 hoverPathNodes/hoverPathEdges 避免影响其他视图
|
|
|
+ store.setHoverEdge({ ...d, source: sourceId, target: targetId })
|
|
|
+
|
|
|
+ // 本地高亮(包括箭头)
|
|
|
+ applyEdgeHoverHighlight(sourceId, targetId, edgeId, d.type)
|
|
|
+}
|
|
|
+
|
|
|
+// 边 hover 离开
|
|
|
+function handleEdgeHoverOut() {
|
|
|
+ if (store.lockedHoverNodeId) return
|
|
|
+
|
|
|
+ store.clearHoverEdge()
|
|
|
+ clearHighlight()
|
|
|
+}
|
|
|
+
|
|
|
+// 边点击
|
|
|
+function handleEdgeClick(event, d) {
|
|
|
+ event.stopPropagation()
|
|
|
+ const sourceId = typeof d.source === 'object' ? d.source.id : d.source
|
|
|
+ const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
|
|
+ store.selectedEdgeId = `${sourceId}|${d.type}|${targetId}`
|
|
|
+ store.selectedNodeId = null
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 高亮效果(使用统一工具函数) ====================
|
|
|
+
|
|
|
+function applyHoverHighlight() {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+ applyHoverHighlightUtil(nodeSelection, linkSelection, null, store.hoverPathNodes, null, store.selectedNodeId)
|
|
|
+}
|
|
|
+
|
|
|
+function applyEdgeHoverHighlight(sourceId, targetId, edgeKey, hoveredEdgeType) {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 节点高亮
|
|
|
+ nodeSelection
|
|
|
+ .classed('dimmed', d => d.id !== sourceId && d.id !== targetId)
|
|
|
+ .classed('highlighted', d => d.id === sourceId || d.id === targetId)
|
|
|
+
|
|
|
+ // 边高亮(包括箭头)
|
|
|
+ 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 thisEdgeKey = `${srcId}|${d.type}|${tgtId}`
|
|
|
+ const isHovered = thisEdgeKey === edgeKey
|
|
|
+
|
|
|
+ d3.select(this)
|
|
|
+ .classed('dimmed', !isHovered)
|
|
|
+ .classed('highlighted', isHovered)
|
|
|
+ // 箭头:高亮边用正常箭头,其他边用置灰箭头
|
|
|
+ .attr('marker-end', d.type === '支撑'
|
|
|
+ ? (isHovered ? `url(#arrow-支撑)` : `url(#arrow-支撑-dimmed)`)
|
|
|
+ : null)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 点击节点高亮(高亮节点及其相关边)
|
|
|
+function applyClickHighlight(clickedId, pathNodes, pathEdges) {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+
|
|
|
+ // 节点高亮
|
|
|
+ nodeSelection
|
|
|
+ .classed('dimmed', d => !pathNodes.has(d.id))
|
|
|
+ .classed('selected', d => d.id === clickedId)
|
|
|
+ .classed('highlighted', d => pathNodes.has(d.id) && d.id !== clickedId)
|
|
|
+
|
|
|
+ // 边高亮(包括箭头)
|
|
|
+ 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 thisEdgeKey = `${srcId}|${d.type}|${tgtId}`
|
|
|
+ const inPath = pathEdges.has(thisEdgeKey)
|
|
|
+
|
|
|
+ d3.select(this)
|
|
|
+ .classed('dimmed', !inPath)
|
|
|
+ .classed('highlighted', inPath)
|
|
|
+ // 箭头:路径内用正常箭头,其他边用置灰箭头
|
|
|
+ .attr('marker-end', d.type === '支撑'
|
|
|
+ ? (inPath ? `url(#arrow-支撑)` : `url(#arrow-支撑-dimmed)`)
|
|
|
+ : null)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function clearHighlight() {
|
|
|
+ if (!nodeSelection || !linkSelection) return
|
|
|
+ clearHoverHighlight(nodeSelection, linkSelection, null)
|
|
|
+
|
|
|
+ // 恢复箭头
|
|
|
+ linkSelection.each(function(d) {
|
|
|
+ const style = getEdgeStyle(d)
|
|
|
+ d3.select(this).attr('marker-end', style.markerId ? `url(#${style.markerId})` : null)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 渲染 ====================
|
|
|
+
|
|
|
+function renderGraph() {
|
|
|
+ if (!svgRef.value || !containerRef.value) return
|
|
|
+
|
|
|
+ const { nodes, edges } = graphData.value
|
|
|
+ if (nodes.length === 0) return
|
|
|
+
|
|
|
+ // 清空
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ svg.selectAll('*').remove()
|
|
|
+
|
|
|
+ const rect = containerRef.value.getBoundingClientRect()
|
|
|
+ const width = rect.width || 400
|
|
|
+ const height = rect.height || 200
|
|
|
+
|
|
|
+ // 创建箭头标记(统一配置)
|
|
|
+ const defs = svg.append('defs')
|
|
|
+ createArrowMarkers(defs)
|
|
|
+
|
|
|
+ // 创建主组
|
|
|
+ mainG = svg.append('g')
|
|
|
+
|
|
|
+ // 缩放
|
|
|
+ currentZoom = d3.zoom()
|
|
|
+ .scaleExtent([0.3, 3])
|
|
|
+ .on('zoom', (e) => mainG.attr('transform', e.transform))
|
|
|
+ svg.call(currentZoom)
|
|
|
+
|
|
|
+ // 深拷贝数据用于 simulation
|
|
|
+ const simNodes = nodes.map(n => ({ ...n }))
|
|
|
+ const simEdges = edges.map(e => ({
|
|
|
+ ...e,
|
|
|
+ source: e.source,
|
|
|
+ target: e.target
|
|
|
+ }))
|
|
|
+
|
|
|
+ // 力导向模拟
|
|
|
+ simulation = d3.forceSimulation(simNodes)
|
|
|
+ .force('link', d3.forceLink(simEdges)
|
|
|
+ .id(d => d.id)
|
|
|
+ .distance(60)
|
|
|
+ )
|
|
|
+ .force('charge', d3.forceManyBody().strength(-150))
|
|
|
+ .force('center', d3.forceCenter(width / 2, height / 2))
|
|
|
+ .force('collision', d3.forceCollide().radius(25))
|
|
|
+
|
|
|
+ // 绘制边
|
|
|
+ linkSelection = mainG.append('g')
|
|
|
+ .attr('class', 'links')
|
|
|
+ .selectAll('line')
|
|
|
+ .data(simEdges)
|
|
|
+ .join('line')
|
|
|
+ .attr('class', 'graph-link')
|
|
|
+ .each(function(d) {
|
|
|
+ const style = getEdgeStyle(d)
|
|
|
+ d3.select(this)
|
|
|
+ .attr('stroke', style.color)
|
|
|
+ .attr('stroke-width', style.strokeWidth)
|
|
|
+ .attr('stroke-opacity', style.opacity)
|
|
|
+ .attr('stroke-dasharray', style.strokeDasharray)
|
|
|
+ .attr('marker-end', style.markerId ? `url(#${style.markerId})` : null)
|
|
|
+ })
|
|
|
+ .on('mouseenter', handleEdgeHover)
|
|
|
+ .on('mouseleave', handleEdgeHoverOut)
|
|
|
+ .on('click', handleEdgeClick)
|
|
|
+
|
|
|
+ // 绘制节点
|
|
|
+ nodeSelection = mainG.append('g')
|
|
|
+ .attr('class', 'nodes')
|
|
|
+ .selectAll('g')
|
|
|
+ .data(simNodes)
|
|
|
+ .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')
|
|
|
+ .each(function(d) {
|
|
|
+ const style = getNodeStyle(d)
|
|
|
+ d3.select(this)
|
|
|
+ .attr('font-size', style.text.fontSize)
|
|
|
+ .attr('fill', style.text.fill)
|
|
|
+ .attr('font-weight', style.text.fontWeight)
|
|
|
+ .text(() => {
|
|
|
+ const name = d.name || ''
|
|
|
+ return name.length > 6 ? name.slice(0, 6) + '…' : 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)
|
|
|
+
|
|
|
+ nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 拖拽函数
|
|
|
+ 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
|
|
|
+ }
|
|
|
+
|
|
|
+ // 初始缩放适应
|
|
|
+ nextTick(() => {
|
|
|
+ setTimeout(() => fitToView(), 300)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 适应视图
|
|
|
+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 + 60),
|
|
|
+ height / (bounds.height + 60),
|
|
|
+ 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 可能在元素不可见时失败
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 监听 ====================
|
|
|
+
|
|
|
+// 监听帖子切换
|
|
|
+watch(() => store.selectedPostIndex, () => {
|
|
|
+ nextTick(() => renderGraph())
|
|
|
+})
|
|
|
+
|
|
|
+watch(() => store.currentPostGraph, () => {
|
|
|
+ nextTick(() => renderGraph())
|
|
|
+}, { deep: true })
|
|
|
+
|
|
|
+// 监听选中状态变化
|
|
|
+watch(() => store.selectedNodeId, (newId) => {
|
|
|
+ if (!nodeSelection) return
|
|
|
+ nodeSelection.classed('selected', d => d.id === newId)
|
|
|
+})
|
|
|
+
|
|
|
+// 监听其他视图的 hover
|
|
|
+watch(() => store.hoverSource, (source) => {
|
|
|
+ if (source && source !== 'relation') {
|
|
|
+ // 其他视图在 hover,同步高亮
|
|
|
+ applyHoverHighlight()
|
|
|
+ } else if (!source) {
|
|
|
+ clearHighlight()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ nextTick(() => renderGraph())
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (simulation) {
|
|
|
+ simulation.stop()
|
|
|
+ simulation = null
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<!-- 使用全局 style.css 中的统一样式 -->
|