|
|
@@ -81,10 +81,6 @@
|
|
|
<!-- SVG 容器 -->
|
|
|
<div ref="containerRef" class="flex-1 relative overflow-hidden">
|
|
|
<svg ref="svgRef" class="w-full h-full transition-opacity duration-200"></svg>
|
|
|
- <!-- 未选中节点时的提示 -->
|
|
|
- <div v-if="!store.selectedNodeId" class="absolute inset-0 flex items-center justify-center text-base-content/30 text-sm">
|
|
|
- 点击人设树节点查看相关图
|
|
|
- </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
@@ -132,6 +128,49 @@ const currentNodeName = computed(() => {
|
|
|
const isPersonaWalk = computed(() => store.selectedNodeId && store.shouldWalk(store.selectedNodeId))
|
|
|
const isPostWalk = computed(() => store.selectedNodeId && store.shouldPostWalk(store.selectedNodeId))
|
|
|
|
|
|
+// BFS 找从 start 到 end 的路径,返回路径上所有节点 ID 的 Set
|
|
|
+function findPath(startId, endId, links) {
|
|
|
+ // 构建邻接表(无向)
|
|
|
+ const adj = new Map()
|
|
|
+ for (const link of links) {
|
|
|
+ const src = link.source.id || link.source
|
|
|
+ const tgt = link.target.id || link.target
|
|
|
+ if (!adj.has(src)) adj.set(src, [])
|
|
|
+ if (!adj.has(tgt)) adj.set(tgt, [])
|
|
|
+ adj.get(src).push(tgt)
|
|
|
+ adj.get(tgt).push(src)
|
|
|
+ }
|
|
|
+
|
|
|
+ // BFS
|
|
|
+ const visited = new Set([startId])
|
|
|
+ const parent = new Map()
|
|
|
+ const queue = [startId]
|
|
|
+
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const curr = queue.shift()
|
|
|
+ if (curr === endId) break
|
|
|
+
|
|
|
+ for (const neighbor of (adj.get(curr) || [])) {
|
|
|
+ if (!visited.has(neighbor)) {
|
|
|
+ visited.add(neighbor)
|
|
|
+ parent.set(neighbor, curr)
|
|
|
+ queue.push(neighbor)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 回溯路径
|
|
|
+ const pathNodes = new Set()
|
|
|
+ if (visited.has(endId)) {
|
|
|
+ let curr = endId
|
|
|
+ while (curr) {
|
|
|
+ pathNodes.add(curr)
|
|
|
+ curr = parent.get(curr)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return pathNodes
|
|
|
+}
|
|
|
+
|
|
|
// 渲染相关图
|
|
|
function renderGraph() {
|
|
|
// 停止旧的 simulation
|
|
|
@@ -143,10 +182,11 @@ function renderGraph() {
|
|
|
const svg = d3.select(svgRef.value)
|
|
|
svg.selectAll('*').remove()
|
|
|
|
|
|
- if (!store.selectedNodeId) return
|
|
|
+ // 有选中节点或选中边时才渲染
|
|
|
+ if (!store.selectedNodeId && !store.selectedEdgeId) return
|
|
|
|
|
|
- // 只有配置的节点类型才显示相关图(人设节点或帖子标签节点)
|
|
|
- if (!store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
|
|
|
+ // 选中节点时,只有配置的节点类型才显示相关图
|
|
|
+ if (store.selectedNodeId && !store.shouldWalk(store.selectedNodeId) && !store.shouldPostWalk(store.selectedNodeId)) return
|
|
|
|
|
|
const container = containerRef.value
|
|
|
if (!container) return
|
|
|
@@ -157,17 +197,15 @@ function renderGraph() {
|
|
|
|
|
|
svg.attr('viewBox', `0 0 ${width} ${height}`)
|
|
|
|
|
|
- const centerNodeId = store.selectedNodeId
|
|
|
- const centerNode = store.getNode(centerNodeId)
|
|
|
-
|
|
|
- if (!centerNode) return
|
|
|
+ // 中心节点(选中节点,或边的第一个端点)
|
|
|
+ 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) {
|
|
|
@@ -181,16 +219,8 @@ function renderGraph() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 如果高亮集合为空(不应该发生),至少显示中心节点
|
|
|
- if (nodes.length === 0) {
|
|
|
- nodes.push({
|
|
|
- id: centerNodeId,
|
|
|
- ...centerNode,
|
|
|
- isCenter: true,
|
|
|
- isHighlighted: false
|
|
|
- })
|
|
|
- nodeSet.add(centerNodeId)
|
|
|
- }
|
|
|
+ // 如果没有节点,不渲染
|
|
|
+ if (nodes.length === 0) return
|
|
|
|
|
|
// 使用游走时记录的边(只显示两端节点都存在的边)
|
|
|
// 优先使用 postWalkedEdges(帖子游走),否则用 walkedEdges(人设游走)
|
|
|
@@ -233,8 +263,13 @@ function renderGraph() {
|
|
|
.style('cursor', 'pointer')
|
|
|
.on('click', (e, d) => {
|
|
|
e.stopPropagation()
|
|
|
- const edgeId = `${d.source.id || d.source}|${d.type}|${d.target.id || d.target}`
|
|
|
- store.selectEdge(edgeId)
|
|
|
+ // 传入完整边数据
|
|
|
+ store.selectEdge({
|
|
|
+ source: d.source.id || d.source,
|
|
|
+ target: d.target.id || d.target,
|
|
|
+ type: d.type,
|
|
|
+ score: d.score
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
// 边的分数标签
|
|
|
@@ -286,6 +321,31 @@ function renderGraph() {
|
|
|
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) {
|
|
|
+ // 高亮路径上的节点和边
|
|
|
+ node.classed('dimmed', n => !pathNodes.has(n.id))
|
|
|
+ link.classed('dimmed', l => {
|
|
|
+ const srcId = l.source.id || l.source
|
|
|
+ const tgtId = l.target.id || l.target
|
|
|
+ return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
|
|
|
+ })
|
|
|
+ linkLabel.classed('dimmed', l => {
|
|
|
+ const srcId = l.source.id || l.source
|
|
|
+ const tgtId = l.target.id || l.target
|
|
|
+ return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .on('mouseleave', () => {
|
|
|
+ // 恢复所有节点和边
|
|
|
+ node.classed('dimmed', false)
|
|
|
+ link.classed('dimmed', false)
|
|
|
+ linkLabel.classed('dimmed', false)
|
|
|
+ })
|
|
|
|
|
|
// 节点形状(使用统一配置)
|
|
|
node.each(function(d) {
|
|
|
@@ -344,6 +404,11 @@ watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () =
|
|
|
nextTick(renderGraph)
|
|
|
})
|
|
|
|
|
|
+// 监听边选中变化
|
|
|
+watch(() => store.selectedEdgeId, () => {
|
|
|
+ nextTick(renderGraph)
|
|
|
+})
|
|
|
+
|
|
|
// 监听配置变化,重新选中触发游走
|
|
|
watch([() => store.walkSteps, () => store.stepConfigs], () => {
|
|
|
if (store.selectedNodeId && isPersonaWalk.value) {
|
|
|
@@ -361,7 +426,7 @@ watch(() => store.postWalkConfig, () => {
|
|
|
// 监听 CSS 过渡结束后重新渲染
|
|
|
function handleTransitionEnd(e) {
|
|
|
if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
|
|
|
- if (store.selectedNodeId && svgRef.value) {
|
|
|
+ if ((store.selectedNodeId || store.selectedEdgeId) && svgRef.value) {
|
|
|
renderGraph()
|
|
|
svgRef.value.style.opacity = '1'
|
|
|
}
|