| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- <template>
- <div class="flex flex-col h-full">
- <!-- 头部 -->
- <div class="flex items-center gap-3 px-4 py-2 bg-base-300 text-xs text-base-content/60 shrink-0">
- <span>相关图</span>
- <span v-if="store.selectedNodeId" class="text-primary font-medium">{{ currentNodeName }}</span>
- <div class="flex-1"></div>
- <button v-if="store.selectedNodeId" @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
- {{ showConfig ? '隐藏筛选' : '筛选' }}
- </button>
- <template v-if="showExpand && store.selectedNodeId">
- <button
- v-if="store.expandedPanel !== 'graph'"
- @click="store.expandPanel('graph')"
- 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>
- </template>
- </div>
- <!-- 人设节点筛选配置 -->
- <div v-show="showConfig && isPersonaWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
- <!-- 步数设置 -->
- <div class="flex items-center gap-2">
- <span class="text-base-content/60 w-16">筛选步数:</span>
- <input type="number" :min="1" :max="5" v-model.number="store.walkSteps" class="input input-xs input-bordered w-16 text-center" />
- </div>
- <!-- 分步设置 -->
- <div class="space-y-2">
- <div v-for="step in store.walkSteps" :key="step" class="pl-4 space-y-1 border-l-2 border-secondary/30">
- <div class="flex items-center gap-2">
- <span class="font-medium text-secondary">第 {{ step }} 步</span>
- <button @click="selectAllEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">全选</button>
- <button @click="clearEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">清空</button>
- <button @click="resetEdgeTypes(step-1)" class="btn btn-ghost btn-xs text-base-content/50">默认</button>
- </div>
- <div class="flex items-center gap-2 flex-wrap">
- <span class="text-base-content/60 w-14">边类型:</span>
- <label v-for="et in store.allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
- <input type="checkbox" v-model="store.stepConfigs[step-1].edgeTypes" :value="et" class="checkbox checkbox-xs" />
- <span :style="{ color: edgeTypeColors[et] }">{{ et }}</span>
- </label>
- </div>
- <div class="flex items-center gap-2">
- <span class="text-base-content/60 w-14">最小分:</span>
- <input type="number" :min="0" :max="1" :step="0.1" v-model.number="store.stepConfigs[step-1].minScore" class="input input-xs input-bordered w-16 text-center" />
- </div>
- </div>
- </div>
- </div>
- <!-- 帖子标签节点筛选配置 -->
- <div v-show="showConfig && isPostWalk" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-2">
- <div class="flex items-center gap-2">
- <span class="text-base-content/60 w-20">最大步数:</span>
- <input type="number" :min="2" :max="10" v-model.number="store.postWalkConfig.maxSteps" class="input input-xs input-bordered w-16 text-center" />
- </div>
- <div class="flex items-center gap-2">
- <span class="text-base-content/60 w-20">最后步分数:</span>
- <input type="number" :min="0" :max="1" :step="0.1" v-model.number="store.postWalkConfig.lastStepMinScore" class="input input-xs input-bordered w-16 text-center" />
- </div>
- <div class="flex items-center gap-2">
- <span class="text-base-content/60 w-20">中间边类型:</span>
- <button @click="selectAllMiddleEdgeTypes" class="btn btn-ghost btn-xs text-base-content/50">全选</button>
- <button @click="clearMiddleEdgeTypes" class="btn btn-ghost btn-xs text-base-content/50">清空</button>
- <button @click="resetMiddleEdgeTypes" class="btn btn-ghost btn-xs text-base-content/50">默认</button>
- </div>
- <div class="flex items-center gap-2 flex-wrap pl-20">
- <label v-for="t in middleEdgeTypeOptions" :key="t" class="flex items-center gap-1 cursor-pointer">
- <input type="checkbox" :value="t" v-model="store.postWalkConfig.middleEdgeTypes" class="checkbox checkbox-xs checkbox-primary" />
- <span>{{ t }}</span>
- </label>
- </div>
- <div class="flex items-center gap-2">
- <span class="text-base-content/60 w-20">中间步分数:</span>
- <input type="number" :min="0" :max="1" :step="0.1" v-model.number="store.postWalkConfig.middleMinScore" class="input input-xs input-bordered w-16 text-center" />
- </div>
- <div class="flex items-center gap-2 text-base-content/50">
- <span>路径: 帖子标签</span>
- <span class="text-primary">--{{ store.postWalkConfig.firstEdgeType }}--></span>
- <span>人设图谱</span>
- <span class="text-primary">--{{ store.postWalkConfig.lastEdgeType }}--></span>
- <span>其他标签</span>
- </div>
- <div v-if="store.postWalkedPaths.length > 0" class="text-success">
- 找到 {{ store.postWalkedPaths.length }} 条路径
- </div>
- </div>
- <!-- SVG 容器 -->
- <div ref="containerRef" class="flex-1 relative overflow-hidden">
- <svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></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 } from '../config/edgeStyle'
- import { applyHighlight, findPath, applyHoverHighlight, clearHoverHighlight } from '../utils/highlight'
- const props = defineProps({
- collapsed: { type: Boolean, default: false },
- showExpand: { type: Boolean, default: false }
- })
- const store = useGraphStore()
- const containerRef = ref(null)
- const svgRef = ref(null)
- const showConfig = ref(false)
- // 中间步骤可选的边类型(排除匹配边)
- const middleEdgeTypeOptions = computed(() => {
- return store.allEdgeTypes.filter(t => t !== '匹配')
- })
- // 中间边类型操作
- function selectAllMiddleEdgeTypes() {
- store.postWalkConfig.middleEdgeTypes = [...middleEdgeTypeOptions.value]
- }
- function clearMiddleEdgeTypes() {
- store.postWalkConfig.middleEdgeTypes = []
- }
- function resetMiddleEdgeTypes() {
- store.postWalkConfig.middleEdgeTypes = ['属于', '包含', '分类共现']
- store.postWalkConfig.middleMinScore = 0.3
- }
- let simulation = null
- // 游走配置操作(直接操作 store)
- function selectAllEdgeTypes(stepIndex) {
- store.stepConfigs[stepIndex].edgeTypes = [...store.allEdgeTypes]
- }
- function clearEdgeTypes(stepIndex) {
- store.stepConfigs[stepIndex].edgeTypes = []
- }
- function resetEdgeTypes(stepIndex) {
- store.stepConfigs[stepIndex].edgeTypes = stepIndex === 0 ? [...store.allEdgeTypes] : ['属于']
- store.stepConfigs[stepIndex].minScore = 0
- }
- const currentNodeName = computed(() => {
- if (!store.selectedNodeId) return '点击左侧节点查看'
- const node = store.getNode(store.selectedNodeId)
- return node ? node.name : store.selectedNodeId
- })
- // 判断当前是哪种游走类型
- const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
- const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
- // 渲染相关图
- function renderGraph() {
- // 停止旧的 simulation
- if (simulation) {
- simulation.stop()
- simulation = null
- }
- const svg = d3.select(svgRef.value)
- svg.selectAll('*').remove()
- // 有选中节点或选中边时才渲染
- if (!store.selectedNodeId && !store.selectedEdgeId) return
- // 选中节点时,只有配置的节点类型才显示相关图
- if (store.selectedNodeId && !store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
- const container = containerRef.value
- if (!container) return
- const width = container.clientWidth
- const height = container.clientHeight
- if (width <= 0 || height <= 0) return
- svg.attr('viewBox', `0 0 ${width} ${height}`)
- // 中心节点(选中节点,或边的第一个端点)
- const centerNodeId = store.selectedNodeId || Array.from(store.highlightedNodeIds)[0]
- // 准备节点和边数据
- const nodes = []
- const links = []
- const nodeSet = new Set()
- // 显示所有高亮节点
- for (const nodeId of store.highlightedNodeIds) {
- const nodeData = store.getNode(nodeId)
- if (nodeData) {
- nodes.push({
- id: nodeId,
- ...nodeData,
- isCenter: nodeId === centerNodeId,
- isHighlighted: store.highlightedNodeIds.size > 1
- })
- nodeSet.add(nodeId)
- }
- }
- // 如果没有节点,不渲染
- if (nodes.length === 0) return
- // 使用游走时记录的边(只显示两端节点都存在的边)
- // 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
- const edges = store.postWalkedEdges.length > 0 ? store.postWalkedEdges : store.walkedEdges
- for (const edge of edges) {
- if (nodeSet.has(edge.source) && nodeSet.has(edge.target)) {
- links.push({ ...edge })
- }
- }
- const g = svg.append('g')
- // 缩放
- const zoom = d3.zoom()
- .scaleExtent([0.3, 3])
- .on('zoom', (e) => g.attr('transform', e.transform))
- svg.call(zoom)
- // 找到中心节点并固定在容器中心
- const centerNodeData = nodes.find(n => n.isCenter)
- if (centerNodeData) {
- centerNodeData.fx = width / 2
- centerNodeData.fy = height / 2
- }
- // 力导向模拟(中心节点已固定,其他节点围绕它布局)
- simulation = d3.forceSimulation(nodes)
- .force('link', d3.forceLink(links).id(d => d.id).distance(80))
- .force('charge', d3.forceManyBody().strength(-150))
- .force('collision', d3.forceCollide().radius(30))
- // 边
- const link = g.append('g')
- .selectAll('line')
- .data(links)
- .join('line')
- .attr('class', 'graph-link')
- .attr('stroke', d => getEdgeStyle(d).color)
- .attr('stroke-width', 1.5)
- .style('cursor', 'pointer')
- .on('click', (e, d) => {
- e.stopPropagation()
- // 传入完整边数据
- store.selectEdge({
- source: d.source.id || d.source,
- target: d.target.id || d.target,
- type: d.type,
- score: d.score
- })
- })
- // 边的分数标签
- const linkLabelData = links.filter(d => getEdgeStyle(d).scoreText)
- const linkLabel = g.append('g')
- .selectAll('g')
- .data(linkLabelData)
- .join('g')
- .attr('class', 'graph-link-label')
- linkLabel.append('rect')
- .attr('x', -14)
- .attr('y', -6)
- .attr('width', 28)
- .attr('height', 12)
- .attr('rx', 2)
- .attr('fill', '#1d232a')
- .attr('opacity', 0.9)
- linkLabel.append('text')
- .attr('text-anchor', 'middle')
- .attr('dy', '0.35em')
- .attr('fill', d => getEdgeStyle(d).color)
- .attr('font-size', '8px')
- .text(d => getEdgeStyle(d).scoreText)
- // 节点组
- const node = g.append('g')
- .selectAll('g')
- .data(nodes)
- .join('g')
- .attr('class', 'graph-node')
- .call(d3.drag()
- .on('start', (e, d) => {
- if (!e.active) simulation.alphaTarget(0.3).restart()
- d.fx = d.x
- d.fy = d.y
- })
- .on('drag', (e, d) => {
- d.fx = e.x
- d.fy = e.y
- })
- .on('end', (e, d) => {
- if (!e.active) simulation.alphaTarget(0)
- d.fx = null
- d.fy = null
- }))
- .on('click', (e, d) => {
- e.stopPropagation()
- store.selectNode(d.id)
- })
- .on('mouseenter', (e, d) => {
- if (d.isCenter) return // 中心节点不处理
- // 找从中心到 hover 节点的路径
- const pathNodes = findPath(centerNodeId, d.id, links)
- if (pathNodes.size > 0) {
- applyHoverHighlight(node, link, linkLabel, pathNodes)
- }
- })
- .on('mouseleave', () => {
- clearHoverHighlight(node, link, linkLabel)
- })
- // 节点形状(使用统一配置)
- node.each(function(d) {
- const el = d3.select(this)
- const style = getNodeStyle(d, { isCenter: d.isCenter })
- applyNodeShape(el, style)
- })
- // 节点标签
- node.append('text')
- .attr('dy', d => getNodeStyle(d, { isCenter: d.isCenter }).size / 2 + 12)
- .attr('text-anchor', 'middle')
- .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
- // 更新位置
- simulation.on('tick', () => {
- link
- .attr('x1', d => d.source.x)
- .attr('y1', d => d.source.y)
- .attr('x2', d => d.target.x)
- .attr('y2', d => d.target.y)
- // 分数标签位置(边的中点)
- linkLabel.attr('transform', d => {
- const midX = (d.source.x + d.target.x) / 2
- const midY = (d.source.y + d.target.y) / 2
- return `translate(${midX},${midY})`
- })
- node.attr('transform', d => `translate(${d.x},${d.y})`)
- })
- // 应用初始高亮状态
- nextTick(updateHighlight)
- }
- // 点击空白取消激活
- function handleSvgClick(event) {
- if (event.target.tagName === 'svg') {
- store.clearSelection()
- }
- }
- // 统一高亮更新
- function updateHighlight() {
- const edgeSet = store.walkedEdgeSet.size > 0 ? store.walkedEdgeSet : store.postWalkedEdgeSet
- applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
- }
- // 监听高亮变化(walkedEdges 或 postWalkedEdges 变化时重新渲染)
- watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () => {
- nextTick(renderGraph)
- })
- // 监听边选中变化
- watch(() => store.selectedEdgeId, () => {
- nextTick(renderGraph)
- nextTick(updateHighlight)
- })
- // 监听高亮节点集合变化
- watch(() => store.highlightedNodeIds.size, updateHighlight)
- // 监听配置变化,重新选中触发游走
- watch([() => store.walkSteps, () => store.stepConfigs], () => {
- if (store.selectedNodeId && isPersonaWalk.value) {
- store.selectNode(store.selectedNodeId)
- }
- }, { deep: true })
- // 监听帖子游走配置变化
- watch(() => store.postWalkConfig, () => {
- if (store.selectedNodeId && isPostWalk.value) {
- store.selectNode(store.selectedNodeId)
- }
- }, { deep: true })
- // 监听 CSS 过渡结束后重新渲染
- function handleTransitionEnd(e) {
- if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
- if ((store.selectedNodeId || store.selectedEdgeId) && svgRef.value) {
- renderGraph()
- svgRef.value.style.opacity = '1'
- }
- }
- }
- // 布局变化时先淡出
- watch(() => store.expandedPanel, () => {
- if (svgRef.value) {
- svgRef.value.style.opacity = '0'
- }
- })
- onMounted(() => {
- nextTick(() => {
- renderGraph()
- // 监听父容器的过渡结束事件
- if (containerRef.value) {
- // 向上找到有 transition 的父容器
- let parent = containerRef.value.parentElement
- while (parent && !parent.classList.contains('transition-all')) {
- parent = parent.parentElement
- }
- if (parent) {
- parent.addEventListener('transitionend', handleTransitionEnd)
- }
- }
- })
- })
- // 组件卸载时清理
- onUnmounted(() => {
- if (containerRef.value) {
- let parent = containerRef.value.parentElement
- while (parent && !parent.classList.contains('transition-all')) {
- parent = parent.parentElement
- }
- if (parent) {
- parent.removeEventListener('transitionend', handleTransitionEnd)
- }
- }
- })
- </script>
|