|
|
@@ -4,8 +4,8 @@
|
|
|
<div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
|
|
|
<span>帖子树</span>
|
|
|
<div class="flex items-center gap-2">
|
|
|
- <span v-if="store.highlightedPostNodeIds.size > 0" class="text-primary">
|
|
|
- 已高亮 {{ store.highlightedPostNodeIds.size }} 个节点
|
|
|
+ <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
|
|
|
+ 已高亮 {{ store.highlightedNodeIds.size }} 个节点
|
|
|
</span>
|
|
|
<template v-if="showExpand">
|
|
|
<button
|
|
|
@@ -50,11 +50,12 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
+import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
import * as d3 from 'd3'
|
|
|
import { useGraphStore } from '../stores/graph'
|
|
|
-import { dimColors, getNodeStyle } from '../config/nodeStyle'
|
|
|
-import { edgeTypeColors, getEdgeStyle } from '../config/edgeStyle'
|
|
|
+import { getNodeStyle } from '../config/nodeStyle'
|
|
|
+import { getEdgeStyle } from '../config/edgeStyle'
|
|
|
+import { applyHighlight } from '../utils/highlight'
|
|
|
|
|
|
const props = defineProps({
|
|
|
showExpand: {
|
|
|
@@ -90,16 +91,14 @@ function formatPostOption(post) {
|
|
|
return date ? `${date} ${shortTitle}` : shortTitle
|
|
|
}
|
|
|
|
|
|
-// 节点元素映射
|
|
|
+// 节点元素映射(统一存储所有节点位置)
|
|
|
let nodeElements = {}
|
|
|
let currentRoot = null
|
|
|
|
|
|
// 处理节点点击
|
|
|
function handleNodeClick(event, d) {
|
|
|
event.stopPropagation()
|
|
|
- const nodeId = d.data.id
|
|
|
- store.selectPostNode(nodeId)
|
|
|
- updateSelection()
|
|
|
+ store.selectNode(d)
|
|
|
}
|
|
|
|
|
|
// 渲染树
|
|
|
@@ -197,12 +196,7 @@ function renderTree() {
|
|
|
.selectAll('.tree-node')
|
|
|
.data(root.descendants())
|
|
|
.join('g')
|
|
|
- .attr('class', d => {
|
|
|
- let cls = 'tree-node'
|
|
|
- if (store.selectedPostNodeId === d.data.id) cls += ' selected'
|
|
|
- if (store.highlightedPostNodeIds.has(d.data.id)) cls += ' highlighted'
|
|
|
- return cls
|
|
|
- })
|
|
|
+ .attr('class', 'tree-node')
|
|
|
.attr('transform', d => `translate(${d.x},${d.y})`)
|
|
|
.style('cursor', 'pointer')
|
|
|
.on('click', handleNodeClick)
|
|
|
@@ -313,57 +307,70 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
persona.y = matchLayerY
|
|
|
})
|
|
|
|
|
|
- // 绘制匹配连线
|
|
|
- const matchLinksG = contentG.append('g').attr('class', 'match-links')
|
|
|
-
|
|
|
+ // 收集匹配边数据(统一数据结构:source, target)
|
|
|
+ const matchLinksData = []
|
|
|
for (const persona of matchedPersonas) {
|
|
|
for (const srcEdge of persona.sourceEdges) {
|
|
|
const sourceNode = nodeElements[srcEdge.sourceId]
|
|
|
if (!sourceNode) continue
|
|
|
-
|
|
|
- // 获取源节点位置(需要减去 contentG 的偏移)
|
|
|
- const srcX = sourceNode.x - 50
|
|
|
- const srcY = sourceNode.y - 25
|
|
|
-
|
|
|
- const midY = (srcY + persona.y) / 2
|
|
|
- const midX = (srcX + persona.x) / 2
|
|
|
- const style = getEdgeStyle({ type: '匹配', score: srcEdge.score })
|
|
|
-
|
|
|
- matchLinksG.append('path')
|
|
|
- .attr('class', 'match-link')
|
|
|
- .attr('fill', 'none')
|
|
|
- .attr('stroke', style.color)
|
|
|
- .attr('stroke-opacity', style.opacity)
|
|
|
- .attr('stroke-width', style.strokeWidth)
|
|
|
- .attr('stroke-dasharray', style.strokeDasharray)
|
|
|
- .attr('d', `M${srcX},${srcY} C${srcX},${midY} ${persona.x},${midY} ${persona.x},${persona.y}`)
|
|
|
-
|
|
|
- // 显示分数(带背景)
|
|
|
- if (style.scoreText) {
|
|
|
- const scoreG = matchLinksG.append('g')
|
|
|
- .attr('transform', `translate(${midX}, ${midY})`)
|
|
|
-
|
|
|
- // 背景矩形
|
|
|
- scoreG.append('rect')
|
|
|
- .attr('x', -14)
|
|
|
- .attr('y', -6)
|
|
|
- .attr('width', 28)
|
|
|
- .attr('height', 12)
|
|
|
- .attr('rx', 2)
|
|
|
- .attr('fill', '#1d232a')
|
|
|
- .attr('opacity', 0.9)
|
|
|
-
|
|
|
- // 分数文字
|
|
|
- scoreG.append('text')
|
|
|
- .attr('text-anchor', 'middle')
|
|
|
- .attr('dy', '0.35em')
|
|
|
- .attr('fill', style.color)
|
|
|
- .attr('font-size', '8px')
|
|
|
- .text(style.scoreText)
|
|
|
- }
|
|
|
+ matchLinksData.push({
|
|
|
+ source: srcEdge.sourceId,
|
|
|
+ target: persona.id,
|
|
|
+ score: srcEdge.score,
|
|
|
+ srcX: sourceNode.x - 50,
|
|
|
+ srcY: sourceNode.y - 25,
|
|
|
+ tgtX: persona.x,
|
|
|
+ tgtY: persona.y
|
|
|
+ })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // 绘制匹配连线(使用 data binding)
|
|
|
+ const matchLinksG = contentG.append('g').attr('class', 'match-links')
|
|
|
+
|
|
|
+ matchLinksG.selectAll('.match-link')
|
|
|
+ .data(matchLinksData)
|
|
|
+ .join('path')
|
|
|
+ .attr('class', 'match-link')
|
|
|
+ .attr('fill', 'none')
|
|
|
+ .attr('stroke', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
|
|
|
+ .attr('stroke-opacity', d => getEdgeStyle({ type: '匹配', score: d.score }).opacity)
|
|
|
+ .attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
|
|
|
+ .attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
|
|
|
+ .attr('d', d => {
|
|
|
+ const midY = (d.srcY + d.tgtY) / 2
|
|
|
+ return `M${d.srcX},${d.srcY} C${d.srcX},${midY} ${d.tgtX},${midY} ${d.tgtX},${d.tgtY}`
|
|
|
+ })
|
|
|
+
|
|
|
+ // 绘制分数标签(使用 data binding)
|
|
|
+ const scoreData = matchLinksData.filter(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
|
|
|
+
|
|
|
+ const scoreGroups = matchLinksG.selectAll('.match-score')
|
|
|
+ .data(scoreData)
|
|
|
+ .join('g')
|
|
|
+ .attr('class', 'match-score')
|
|
|
+ .attr('transform', d => {
|
|
|
+ const midX = (d.srcX + d.tgtX) / 2
|
|
|
+ const midY = (d.srcY + d.tgtY) / 2
|
|
|
+ return `translate(${midX}, ${midY})`
|
|
|
+ })
|
|
|
+
|
|
|
+ scoreGroups.append('rect')
|
|
|
+ .attr('x', -14)
|
|
|
+ .attr('y', -6)
|
|
|
+ .attr('width', 28)
|
|
|
+ .attr('height', 12)
|
|
|
+ .attr('rx', 2)
|
|
|
+ .attr('fill', '#1d232a')
|
|
|
+ .attr('opacity', 0.9)
|
|
|
+
|
|
|
+ scoreGroups.append('text')
|
|
|
+ .attr('text-anchor', 'middle')
|
|
|
+ .attr('dy', '0.35em')
|
|
|
+ .attr('fill', d => getEdgeStyle({ type: '匹配', score: d.score }).color)
|
|
|
+ .attr('font-size', '8px')
|
|
|
+ .text(d => getEdgeStyle({ type: '匹配', score: d.score }).scoreText)
|
|
|
+
|
|
|
// 绘制匹配节点
|
|
|
const matchNodesG = contentG.append('g').attr('class', 'match-nodes')
|
|
|
|
|
|
@@ -399,6 +406,9 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
.attr('stroke', 'rgba(255,255,255,0.5)')
|
|
|
.attr('stroke-width', 1)
|
|
|
}
|
|
|
+
|
|
|
+ // 保存匹配节点位置(统一存入 nodeElements)
|
|
|
+ nodeElements[d.id] = { element: this, x: d.x + 50, y: d.y + 25 }
|
|
|
})
|
|
|
|
|
|
// 匹配节点标签(使用统一配置)
|
|
|
@@ -422,11 +432,7 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
// 匹配节点点击处理
|
|
|
function handleMatchNodeClick(event, d) {
|
|
|
event.stopPropagation()
|
|
|
- // 在人设树中选中对应节点
|
|
|
- const personaNodeId = d.id.replace('人设:', '')
|
|
|
- // 可以触发一个事件让左边的人设树高亮
|
|
|
- console.log('点击匹配节点:', d.id, d.name)
|
|
|
- // TODO: 联动人设树
|
|
|
+ store.selectNode(d)
|
|
|
}
|
|
|
|
|
|
// 适应视图(自动缩放以显示全部内容)
|
|
|
@@ -471,57 +477,28 @@ function zoomToNode(nodeId) {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-// 更新选中/高亮状态
|
|
|
-function updateSelection() {
|
|
|
- const svg = d3.select(svgRef.value)
|
|
|
- const hasHighlight = store.highlightedPostNodeIds.size > 0
|
|
|
-
|
|
|
- svg.selectAll('.tree-node')
|
|
|
- .classed('selected', d => store.selectedPostNodeId === d.data.id)
|
|
|
- .classed('highlighted', d => store.highlightedPostNodeIds.has(d.data.id))
|
|
|
- .classed('dimmed', d => hasHighlight && !store.highlightedPostNodeIds.has(d.data.id))
|
|
|
-
|
|
|
- svg.selectAll('.tree-link')
|
|
|
- .classed('highlighted', d => {
|
|
|
- return store.highlightedPostNodeIds.has(d.source.data.id) &&
|
|
|
- store.highlightedPostNodeIds.has(d.target.data.id)
|
|
|
- })
|
|
|
- .classed('dimmed', d => {
|
|
|
- return hasHighlight && !(store.highlightedPostNodeIds.has(d.source.data.id) &&
|
|
|
- store.highlightedPostNodeIds.has(d.target.data.id))
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-// 清除高亮
|
|
|
-function clearHighlight() {
|
|
|
- store.clearPostSelection()
|
|
|
- updateSelection()
|
|
|
+// 更新高亮/置灰状态
|
|
|
+function updateHighlight() {
|
|
|
+ applyHighlight(svgRef.value, store.highlightedNodeIds, store.walkedEdgeSet, store.selectedNodeId)
|
|
|
}
|
|
|
|
|
|
// 点击空白取消激活
|
|
|
function handleSvgClick(event) {
|
|
|
- // 检查是否点击了节点或文字,如果不是则取消激活
|
|
|
const target = event.target
|
|
|
- const isNode = target.closest('.tree-node')
|
|
|
- if (!isNode) {
|
|
|
- clearHighlight()
|
|
|
+ if (!target.closest('.tree-node') && !target.closest('.match-node')) {
|
|
|
+ store.clearSelection()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 监听选中变化
|
|
|
-watch(() => store.selectedPostNodeId, (nodeId, oldNodeId) => {
|
|
|
+// 监听选中/高亮变化,统一更新
|
|
|
+watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
|
|
|
+ updateHighlight()
|
|
|
if (nodeId && nodeId !== oldNodeId) {
|
|
|
- updateSelection()
|
|
|
zoomToNode(nodeId)
|
|
|
- } else if (!nodeId) {
|
|
|
- updateSelection()
|
|
|
}
|
|
|
})
|
|
|
|
|
|
-// 监听高亮变化
|
|
|
-watch(() => store.highlightedPostNodeIds.size, () => {
|
|
|
- updateSelection()
|
|
|
-})
|
|
|
+watch(() => store.highlightedNodeIds.size, updateHighlight)
|
|
|
|
|
|
// 监听当前帖子变化,重新渲染树
|
|
|
watch(() => store.currentPostGraph, () => {
|