|
|
@@ -135,13 +135,18 @@
|
|
|
<div class="flex items-center gap-1.5">
|
|
|
<!-- hover 标识 -->
|
|
|
<span v-if="store.hoverNode" class="text-[9px] text-warning/60">[hover]</span>
|
|
|
- <!-- 节点样式:空心(帖子域)或实心(人设域) -->
|
|
|
+ <!-- 节点样式:空心(帖子域)或实心(人设域),激活状态有发光效果 -->
|
|
|
<span
|
|
|
class="w-2 h-2 shrink-0"
|
|
|
:class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
|
|
|
- :style="displayNodeStyle.hollow
|
|
|
- ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
|
|
|
- : { backgroundColor: displayNodeStyle.color }"
|
|
|
+ :style="{
|
|
|
+ ...(displayNodeStyle.hollow
|
|
|
+ ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
|
|
|
+ : { backgroundColor: displayNodeStyle.color }),
|
|
|
+ ...(store.selectedNode && !store.hoverNode
|
|
|
+ ? { filter: 'brightness(1.2)', boxShadow: '0 0 4px ' + displayNodeStyle.color }
|
|
|
+ : {})
|
|
|
+ }"
|
|
|
></span>
|
|
|
<span class="text-primary font-medium text-[11px] break-all">{{ displayNode.name }}</span>
|
|
|
</div>
|
|
|
@@ -373,8 +378,8 @@ const props = defineProps({
|
|
|
|
|
|
const emit = defineEmits(['update:matchListCollapsed'])
|
|
|
|
|
|
-// 不需要显示的节点字段
|
|
|
-const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy']
|
|
|
+// 不需要显示的节点字段(D3相关字段和children)
|
|
|
+const hiddenNodeFields = ['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy', 'children', 'id', 'data', 'parent', 'depth', 'height']
|
|
|
|
|
|
const store = useGraphStore()
|
|
|
|
|
|
@@ -562,32 +567,62 @@ function getNodeColor(node) {
|
|
|
return '#888'
|
|
|
}
|
|
|
|
|
|
-// 节点的入边列表(按分数降序)
|
|
|
+// 节点的入边列表(按分数降序,同时查找帖子图谱和人设图谱)
|
|
|
const nodeInEdges = computed(() => {
|
|
|
if (!displayNode.value) return []
|
|
|
const nodeId = displayNode.value.id || displayNode.value.data?.id
|
|
|
if (!nodeId) return []
|
|
|
|
|
|
+ const edges = []
|
|
|
+
|
|
|
+ // 从帖子图谱查找
|
|
|
const postGraph = store.currentPostGraph
|
|
|
- if (!postGraph?.edges) return []
|
|
|
+ if (postGraph?.edges) {
|
|
|
+ for (const e of Object.values(postGraph.edges)) {
|
|
|
+ if (e.target === nodeId) edges.push(e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 从人设图谱查找(如果是人设节点)
|
|
|
+ if (nodeId.startsWith('人设:')) {
|
|
|
+ const personaEdges = store.graphData?.edges
|
|
|
+ if (personaEdges) {
|
|
|
+ for (const e of Object.values(personaEdges)) {
|
|
|
+ if (e.target === nodeId) edges.push(e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- return Object.values(postGraph.edges)
|
|
|
- .filter(e => e.target === nodeId)
|
|
|
- .sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
|
+ return edges.sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
|
})
|
|
|
|
|
|
-// 节点的出边列表(按分数降序)
|
|
|
+// 节点的出边列表(按分数降序,同时查找帖子图谱和人设图谱)
|
|
|
const nodeOutEdges = computed(() => {
|
|
|
if (!displayNode.value) return []
|
|
|
const nodeId = displayNode.value.id || displayNode.value.data?.id
|
|
|
if (!nodeId) return []
|
|
|
|
|
|
+ const edges = []
|
|
|
+
|
|
|
+ // 从帖子图谱查找
|
|
|
const postGraph = store.currentPostGraph
|
|
|
- if (!postGraph?.edges) return []
|
|
|
+ if (postGraph?.edges) {
|
|
|
+ for (const e of Object.values(postGraph.edges)) {
|
|
|
+ if (e.source === nodeId) edges.push(e)
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- return Object.values(postGraph.edges)
|
|
|
- .filter(e => e.source === nodeId)
|
|
|
- .sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
|
+ // 从人设图谱查找(如果是人设节点)
|
|
|
+ if (nodeId.startsWith('人设:')) {
|
|
|
+ const personaEdges = store.graphData?.edges
|
|
|
+ if (personaEdges) {
|
|
|
+ for (const e of Object.values(personaEdges)) {
|
|
|
+ if (e.source === nodeId) edges.push(e)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return edges.sort((a, b) => (b.score || 0) - (a.score || 0))
|
|
|
})
|
|
|
|
|
|
// 获取节点名称(根据节点ID)
|
|
|
@@ -819,23 +854,33 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
|
|
|
if (matchEdges.length === 0) return
|
|
|
|
|
|
- // 收集匹配的人设节点(去重)
|
|
|
+ // 收集匹配的人设节点(去重),获取完整节点数据
|
|
|
const matchedPersonaMap = new Map()
|
|
|
for (const edge of matchEdges) {
|
|
|
if (!matchedPersonaMap.has(edge.target)) {
|
|
|
- // 从人设节点ID提取信息: "人设:目的点:标签:进行产品种草"
|
|
|
- const parts = edge.target.split(':')
|
|
|
- const name = parts[parts.length - 1]
|
|
|
- const dimension = parts[1] // 灵感点/目的点/关键点
|
|
|
- const type = parts[2] // 标签/分类/点
|
|
|
- matchedPersonaMap.set(edge.target, {
|
|
|
- id: edge.target,
|
|
|
- name: name,
|
|
|
- dimension: dimension,
|
|
|
- type: type,
|
|
|
- domain: '人设', // 人设节点:实心
|
|
|
- sourceEdges: [] // 连接的帖子节点
|
|
|
- })
|
|
|
+ // 从 store 获取完整的人设节点数据
|
|
|
+ const fullNode = store.getNode(edge.target)
|
|
|
+ if (fullNode) {
|
|
|
+ matchedPersonaMap.set(edge.target, {
|
|
|
+ ...fullNode,
|
|
|
+ id: edge.target,
|
|
|
+ sourceEdges: [] // 连接的帖子节点
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ // 回退:从节点ID提取基本信息
|
|
|
+ const parts = edge.target.split(':')
|
|
|
+ const name = parts[parts.length - 1]
|
|
|
+ const dimension = parts[1]
|
|
|
+ const type = parts[2]
|
|
|
+ matchedPersonaMap.set(edge.target, {
|
|
|
+ id: edge.target,
|
|
|
+ name: name,
|
|
|
+ dimension: dimension,
|
|
|
+ type: type,
|
|
|
+ domain: '人设',
|
|
|
+ sourceEdges: []
|
|
|
+ })
|
|
|
+ }
|
|
|
}
|
|
|
matchedPersonaMap.get(edge.target).sourceEdges.push({
|
|
|
sourceId: edge.source,
|
|
|
@@ -1402,6 +1447,69 @@ function handleLockClick() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// 基于当前高亮的边集合计算从 fromId 到 toId 的路径
|
|
|
+function computePathInHighlightedEdges(fromId, toId) {
|
|
|
+ // 使用当前高亮的边集合
|
|
|
+ const edgeSet = store.postWalkedEdgeSet.size > 0 ? store.postWalkedEdgeSet : store.walkedEdgeSet
|
|
|
+ if (edgeSet.size === 0) return { nodes: new Set(), edges: new Set() }
|
|
|
+
|
|
|
+ // hover 节点必须在高亮节点中
|
|
|
+ if (!store.highlightedNodeIds.has(fromId)) return { nodes: new Set(), edges: new Set() }
|
|
|
+
|
|
|
+ // 构建邻接表(只包含高亮的边)
|
|
|
+ const adj = new Map()
|
|
|
+ for (const edgeKey of edgeSet) {
|
|
|
+ const [srcId, tgtId] = edgeKey.split('->')
|
|
|
+ if (!srcId || !tgtId) continue
|
|
|
+
|
|
|
+ if (!adj.has(srcId)) adj.set(srcId, [])
|
|
|
+ if (!adj.has(tgtId)) adj.set(tgtId, [])
|
|
|
+ adj.get(srcId).push({ neighbor: tgtId, edgeKey })
|
|
|
+ adj.get(tgtId).push({ neighbor: srcId, edgeKey: `${tgtId}->${srcId}` })
|
|
|
+ }
|
|
|
+
|
|
|
+ // BFS 找路径
|
|
|
+ const visited = new Set([fromId])
|
|
|
+ const parent = new Map()
|
|
|
+ const parentEdge = new Map()
|
|
|
+ const queue = [fromId]
|
|
|
+
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const curr = queue.shift()
|
|
|
+ if (curr === toId) break
|
|
|
+
|
|
|
+ for (const { neighbor, edgeKey } of (adj.get(curr) || [])) {
|
|
|
+ if (!visited.has(neighbor)) {
|
|
|
+ visited.add(neighbor)
|
|
|
+ parent.set(neighbor, curr)
|
|
|
+ parentEdge.set(neighbor, edgeKey)
|
|
|
+ queue.push(neighbor)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 回溯路径
|
|
|
+ const pathNodes = new Set()
|
|
|
+ const pathEdges = new Set()
|
|
|
+
|
|
|
+ if (visited.has(toId)) {
|
|
|
+ let curr = toId
|
|
|
+ while (curr) {
|
|
|
+ pathNodes.add(curr)
|
|
|
+ const edgeKey = parentEdge.get(curr)
|
|
|
+ if (edgeKey) {
|
|
|
+ pathEdges.add(edgeKey)
|
|
|
+ // 添加反向边
|
|
|
+ const [src, tgt] = edgeKey.split('->')
|
|
|
+ pathEdges.add(`${tgt}->${src}`)
|
|
|
+ }
|
|
|
+ curr = parent.get(curr)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { nodes: pathNodes, edges: pathEdges }
|
|
|
+}
|
|
|
+
|
|
|
// 设置 hover 处理器(在所有元素创建后调用)
|
|
|
function setupHoverHandlers() {
|
|
|
if (!svgRef.value || !store.selectedNodeId) return
|
|
|
@@ -1418,18 +1526,28 @@ function setupHoverHandlers() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 添加 hover 处理器(路径计算由 store 统一处理)
|
|
|
+ // 添加 hover 处理器(基于当前高亮的边集合计算路径)
|
|
|
allNodes
|
|
|
.on('mouseenter', function(event, d) {
|
|
|
const nodeId = d.data?.id || d.id
|
|
|
// 排除起始节点
|
|
|
if (nodeId === startNodeId) return
|
|
|
|
|
|
- store.computeHoverPath(startNodeId, nodeId, 'post-tree')
|
|
|
- store.setHoverNode(d) // 设置 hover 节点数据用于详情显示
|
|
|
+ // 基于当前高亮的边集合计算路径
|
|
|
+ const { nodes: pathNodes, edges: pathEdges } = computePathInHighlightedEdges(nodeId, startNodeId)
|
|
|
+
|
|
|
+ // 如果找不到路径,不做任何处理
|
|
|
+ if (pathNodes.size === 0) return
|
|
|
+
|
|
|
+ // 更新 store 状态
|
|
|
+ store.hoverNodeId = nodeId
|
|
|
+ store.hoverPathNodes = pathNodes
|
|
|
+ store.hoverPathEdges = pathEdges
|
|
|
+ store.hoverSource = 'post-tree'
|
|
|
+ store.setHoverNode(d)
|
|
|
|
|
|
// 显示锁定按钮(在当前hover节点上)
|
|
|
- if (store.hoverPathNodes.size > 0) {
|
|
|
+ if (pathNodes.size > 0) {
|
|
|
// 如果已锁定,按钮显示在锁定节点上
|
|
|
if (store.lockedHoverNodeId) {
|
|
|
const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
|
|
|
@@ -1446,14 +1564,8 @@ function setupHoverHandlers() {
|
|
|
// 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
|
|
|
store.clearHover()
|
|
|
|
|
|
+ // 只管理锁定按钮的显示,高亮由 watch 统一处理
|
|
|
if (store.lockedHoverNodeId) {
|
|
|
- // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
|
|
|
- const svg = d3.select(svgRef.value)
|
|
|
- const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|
|
|
- const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
|
|
|
- const allLabels = svg.selectAll('.match-score, .walked-score')
|
|
|
- // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
|
|
|
- applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, null)
|
|
|
// 在锁定节点上显示解锁按钮
|
|
|
const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
|
|
|
if (lockedNodeInfo?.element) {
|
|
|
@@ -1705,6 +1817,59 @@ watch(() => store.focusEdgeEndpoints, (endpoints) => {
|
|
|
|
|
|
watch(() => store.highlightedNodeIds.size, updateHighlight)
|
|
|
|
|
|
+// 应用基于边集合的 hover 高亮
|
|
|
+function applyPathHighlightWithEdges(pathNodes, pathEdges) {
|
|
|
+ if (!svgRef.value) return
|
|
|
+
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|
|
|
+ const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
|
|
|
+ const allLabels = svg.selectAll('.match-score, .walked-score')
|
|
|
+
|
|
|
+ // 先清除所有残留的高亮类(但保留 selected 类给选中节点)
|
|
|
+ allNodes
|
|
|
+ .classed('highlighted', false)
|
|
|
+ .classed('locked-path', false)
|
|
|
+ allLinks
|
|
|
+ .classed('highlighted', false)
|
|
|
+ .classed('locked-path', false)
|
|
|
+ allLabels
|
|
|
+ .classed('highlighted', false)
|
|
|
+ .classed('locked-path', false)
|
|
|
+
|
|
|
+ // 节点高亮(保持选中节点的 selected 样式)
|
|
|
+ const activeNodeId = store.selectedNodeId
|
|
|
+ allNodes
|
|
|
+ .classed('dimmed', function(d) {
|
|
|
+ const nodeId = d.data?.id || d.id
|
|
|
+ return !pathNodes.has(nodeId)
|
|
|
+ })
|
|
|
+ .classed('selected', function(d) {
|
|
|
+ const nodeId = d.data?.id || d.id
|
|
|
+ return nodeId === activeNodeId
|
|
|
+ })
|
|
|
+
|
|
|
+ // 边高亮(基于边集合判断)
|
|
|
+ allLinks.each(function(d) {
|
|
|
+ const srcId = d.source?.data?.id || d.source?.id || d.source
|
|
|
+ const tgtId = d.target?.data?.id || d.target?.id || d.target
|
|
|
+ const edgeKey = `${srcId}->${tgtId}`
|
|
|
+ const edgeKeyRev = `${tgtId}->${srcId}`
|
|
|
+ const inPath = pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
|
|
|
+ d3.select(this).classed('dimmed', !inPath)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 标签高亮
|
|
|
+ allLabels.each(function(d) {
|
|
|
+ const srcId = d.source?.data?.id || d.source?.id || d.source
|
|
|
+ const tgtId = d.target?.data?.id || d.target?.id || d.target
|
|
|
+ const edgeKey = `${srcId}->${tgtId}`
|
|
|
+ const edgeKeyRev = `${tgtId}->${srcId}`
|
|
|
+ const inPath = pathEdges.has(edgeKey) || pathEdges.has(edgeKeyRev)
|
|
|
+ d3.select(this).classed('dimmed', !inPath)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
// 监听 hover 状态变化(用于左右联动)
|
|
|
watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
|
|
|
if (!svgRef.value) return
|
|
|
@@ -1712,16 +1877,23 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
|
|
|
// 边 hover 时不处理,避免覆盖边 hover 高亮
|
|
|
if (isEdgeHovering) return
|
|
|
|
|
|
- const svg = d3.select(svgRef.value)
|
|
|
+ // hover 结束时,无论来源是什么,都恢复原有高亮
|
|
|
+ if (store.hoverPathNodes.size === 0) {
|
|
|
+ updateHighlight()
|
|
|
+ if (!store.lockedHoverNodeId) {
|
|
|
+ hideLockButton()
|
|
|
+ }
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|
|
|
- const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
|
|
|
- const allLabels = svg.selectAll('.match-score, .walked-score')
|
|
|
+ // 推导图谱的 hover 不影响待解构图的高亮(保持自己的激活状态)
|
|
|
+ if (store.hoverSource === 'derivation') {
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- if (store.hoverPathNodes.size > 0) {
|
|
|
- // 应用 hover 高亮(支持嵌套:传入锁定路径)
|
|
|
- const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
|
|
|
- applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
|
|
|
+ if (store.hoverPathNodes.size > 0 && store.hoverPathEdges.size > 0) {
|
|
|
+ // 使用边集合来精确判断哪些边应该高亮
|
|
|
+ applyPathHighlightWithEdges(store.hoverPathNodes, store.hoverPathEdges)
|
|
|
|
|
|
// 如果是从 GraphView 触发的,缩放到显示完整路径
|
|
|
if (store.hoverSource === 'graph') {
|
|
|
@@ -1735,12 +1907,20 @@ watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
|
|
|
showLockButton(nodeInfo.element)
|
|
|
}
|
|
|
}
|
|
|
- } else {
|
|
|
- // 清除 hover,恢复原有高亮
|
|
|
- updateHighlight()
|
|
|
- // 如果没有锁定,隐藏按钮
|
|
|
- if (!store.lockedHoverNodeId) {
|
|
|
- hideLockButton()
|
|
|
+ } else if (store.hoverPathNodes.size > 0) {
|
|
|
+ // 兼容旧逻辑(没有边集合时)
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
+ const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|
|
|
+ const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
|
|
|
+ const allLabels = svg.selectAll('.match-score, .walked-score')
|
|
|
+ const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
|
|
|
+ applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath, store.selectedNodeId)
|
|
|
+
|
|
|
+ if (store.hoverNodeId) {
|
|
|
+ const nodeInfo = nodeElements[store.hoverNodeId]
|
|
|
+ if (nodeInfo?.element) {
|
|
|
+ showLockButton(nodeInfo.element)
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
})
|
|
|
@@ -1812,10 +1992,10 @@ function restoreLockedHover() {
|
|
|
const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
|
|
|
const allLabels = svg.selectAll('.match-score, .walked-score')
|
|
|
|
|
|
- // 恢复高亮效果(传入锁定路径)
|
|
|
+ // 恢复高亮效果(传入锁定路径和选中节点)
|
|
|
if (store.hoverPathNodes.size > 0) {
|
|
|
const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
|
|
|
- applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
|
|
|
+ applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath, store.selectedNodeId)
|
|
|
}
|
|
|
|
|
|
// 恢复锁定按钮
|