|
@@ -105,15 +105,15 @@
|
|
|
|
|
|
|
|
<!-- 详情 -->
|
|
<!-- 详情 -->
|
|
|
<div v-if="showDetail" class="flex-1 flex flex-col min-h-0">
|
|
<div v-if="showDetail" class="flex-1 flex flex-col min-h-0">
|
|
|
- <div class="px-3 py-2 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between">
|
|
|
|
|
|
|
+ <div class="px-2 py-1.5 bg-base-300 text-base-content/60 shrink-0 flex items-center justify-between text-[11px]">
|
|
|
<span>详情</span>
|
|
<span>详情</span>
|
|
|
- <label class="swap swap-flip text-[10px]">
|
|
|
|
|
|
|
+ <label class="swap swap-flip text-[9px]">
|
|
|
<input type="checkbox" v-model="showRawData" />
|
|
<input type="checkbox" v-model="showRawData" />
|
|
|
<span class="swap-on">JSON</span>
|
|
<span class="swap-on">JSON</span>
|
|
|
<span class="swap-off">渲染</span>
|
|
<span class="swap-off">渲染</span>
|
|
|
</label>
|
|
</label>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="flex-1 overflow-y-auto p-3 space-y-3">
|
|
|
|
|
|
|
+ <div class="flex-1 overflow-y-auto px-2 py-1.5 space-y-1.5">
|
|
|
<!-- 原始JSON模式 -->
|
|
<!-- 原始JSON模式 -->
|
|
|
<template v-if="showRawData && (displayNode || store.selectedEdge)">
|
|
<template v-if="showRawData && (displayNode || store.selectedEdge)">
|
|
|
<div class="relative">
|
|
<div class="relative">
|
|
@@ -132,33 +132,33 @@
|
|
|
<template v-else-if="!showRawData">
|
|
<template v-else-if="!showRawData">
|
|
|
<!-- 节点详情(hover 优先于 selected) -->
|
|
<!-- 节点详情(hover 优先于 selected) -->
|
|
|
<template v-if="displayNode">
|
|
<template v-if="displayNode">
|
|
|
- <div class="flex items-center gap-2">
|
|
|
|
|
|
|
+ <div class="flex items-center gap-1.5">
|
|
|
<!-- hover 标识 -->
|
|
<!-- hover 标识 -->
|
|
|
- <span v-if="store.hoverNode" class="text-[10px] text-warning/60">[hover]</span>
|
|
|
|
|
|
|
+ <span v-if="store.hoverNode" class="text-[9px] text-warning/60">[hover]</span>
|
|
|
<!-- 节点样式:空心(帖子域)或实心(人设域) -->
|
|
<!-- 节点样式:空心(帖子域)或实心(人设域) -->
|
|
|
<span
|
|
<span
|
|
|
- class="w-2.5 h-2.5 shrink-0"
|
|
|
|
|
|
|
+ class="w-2 h-2 shrink-0"
|
|
|
:class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
|
|
:class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
|
|
|
:style="displayNodeStyle.hollow
|
|
:style="displayNodeStyle.hollow
|
|
|
? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
|
|
? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
|
|
|
: { backgroundColor: displayNodeStyle.color }"
|
|
: { backgroundColor: displayNodeStyle.color }"
|
|
|
></span>
|
|
></span>
|
|
|
- <span class="text-primary font-medium truncate">{{ displayNode.name }}</span>
|
|
|
|
|
|
|
+ <span class="text-primary font-medium text-[11px] break-all">{{ displayNode.name }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="space-y-1.5 text-[11px]">
|
|
|
|
|
|
|
+ <div class="space-y-0.5 text-[10px]">
|
|
|
<template v-for="(value, key) in displayNode" :key="key">
|
|
<template v-for="(value, key) in displayNode" :key="key">
|
|
|
<template v-if="!hiddenNodeFields.includes(key) && key !== 'name' && value !== null && value !== undefined && value !== ''">
|
|
<template v-if="!hiddenNodeFields.includes(key) && key !== 'name' && value !== null && value !== undefined && value !== ''">
|
|
|
- <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
|
|
|
|
|
- <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
|
|
|
|
|
- <span class="text-right break-all">{{ formatValue(value) }}</span>
|
|
|
|
|
|
|
+ <div v-if="typeof value !== 'object'" class="flex gap-1">
|
|
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}:</span>
|
|
|
|
|
+ <span class="break-all">{{ formatValue(value) }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
|
|
|
|
|
- <span class="text-base-content/50">{{ formatKey(key) }}</span>
|
|
|
|
|
- <div class="pl-2 border-l border-base-content/20 space-y-1">
|
|
|
|
|
|
|
+ <div v-else-if="Object.keys(value).length > 0" class="space-y-0.5">
|
|
|
|
|
+ <span class="text-base-content/50">{{ formatKey(key) }}:</span>
|
|
|
|
|
+ <div class="pl-2 border-l border-base-content/20 space-y-0.5 text-[9px]">
|
|
|
<template v-for="(v, k) in value" :key="k">
|
|
<template v-for="(v, k) in value" :key="k">
|
|
|
- <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
|
|
|
|
|
- <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
|
|
|
|
|
- <span class="text-right break-all">{{ formatValue(v) }}</span>
|
|
|
|
|
|
|
+ <div v-if="v !== null && v !== undefined && v !== ''" class="flex gap-1">
|
|
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}:</span>
|
|
|
|
|
+ <span class="break-all">{{ formatValue(v) }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
</div>
|
|
</div>
|
|
@@ -167,70 +167,81 @@
|
|
|
</template>
|
|
</template>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- 入边列表 -->
|
|
<!-- 入边列表 -->
|
|
|
- <div v-if="nodeInEdges.length > 0" class="mt-3 pt-2 border-t border-base-content/10">
|
|
|
|
|
|
|
+ <div v-if="nodeInEdges.length > 0" class="mt-2 pt-1.5 border-t border-base-content/10">
|
|
|
<div
|
|
<div
|
|
|
- class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
|
|
|
|
|
|
|
+ class="text-[9px] text-base-content/50 mb-0.5 cursor-pointer hover:text-primary"
|
|
|
@click="openEdgeListModal('in', nodeInEdges)"
|
|
@click="openEdgeListModal('in', nodeInEdges)"
|
|
|
>入边 ({{ nodeInEdges.length }}) ›</div>
|
|
>入边 ({{ nodeInEdges.length }}) ›</div>
|
|
|
- <div class="space-y-1 max-h-24 overflow-y-auto">
|
|
|
|
|
|
|
+ <div class="space-y-0.5 max-h-20 overflow-y-auto">
|
|
|
<div
|
|
<div
|
|
|
v-for="edge in nodeInEdges"
|
|
v-for="edge in nodeInEdges"
|
|
|
:key="`in-${edge.source}-${edge.type}`"
|
|
:key="`in-${edge.source}-${edge.type}`"
|
|
|
- class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
|
|
|
|
|
|
|
+ class="flex items-center gap-1 text-[9px] px-0.5 py-0.5 rounded hover:bg-base-300 cursor-pointer"
|
|
|
@click="openEdgeModal(edge)"
|
|
@click="openEdgeModal(edge)"
|
|
|
>
|
|
>
|
|
|
- <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
|
|
|
|
|
|
|
+ <span class="w-1.5 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
|
|
|
<span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
|
|
<span class="truncate flex-1">{{ getNodeName(edge.source) }}</span>
|
|
|
- <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
|
|
|
|
|
|
|
+ <span class="text-base-content/40 shrink-0">{{ edge.score?.toFixed(2) || '-' }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
<!-- 出边列表 -->
|
|
<!-- 出边列表 -->
|
|
|
- <div v-if="nodeOutEdges.length > 0" class="mt-2 pt-2 border-t border-base-content/10">
|
|
|
|
|
|
|
+ <div v-if="nodeOutEdges.length > 0" class="mt-1.5 pt-1.5 border-t border-base-content/10">
|
|
|
<div
|
|
<div
|
|
|
- class="text-[10px] text-base-content/50 mb-1 cursor-pointer hover:text-primary"
|
|
|
|
|
|
|
+ class="text-[9px] text-base-content/50 mb-0.5 cursor-pointer hover:text-primary"
|
|
|
@click="openEdgeListModal('out', nodeOutEdges)"
|
|
@click="openEdgeListModal('out', nodeOutEdges)"
|
|
|
>出边 ({{ nodeOutEdges.length }}) ›</div>
|
|
>出边 ({{ nodeOutEdges.length }}) ›</div>
|
|
|
- <div class="space-y-1 max-h-24 overflow-y-auto">
|
|
|
|
|
|
|
+ <div class="space-y-0.5 max-h-20 overflow-y-auto">
|
|
|
<div
|
|
<div
|
|
|
v-for="edge in nodeOutEdges"
|
|
v-for="edge in nodeOutEdges"
|
|
|
:key="`out-${edge.target}-${edge.type}`"
|
|
:key="`out-${edge.target}-${edge.type}`"
|
|
|
- class="flex items-center gap-1 text-[10px] px-1 py-0.5 rounded hover:bg-base-300 cursor-pointer"
|
|
|
|
|
|
|
+ class="flex items-center gap-1 text-[9px] px-0.5 py-0.5 rounded hover:bg-base-300 cursor-pointer"
|
|
|
@click="openEdgeModal(edge)"
|
|
@click="openEdgeModal(edge)"
|
|
|
>
|
|
>
|
|
|
- <span class="w-2 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
|
|
|
|
|
|
|
+ <span class="w-1.5 h-0.5" :style="{ backgroundColor: edgeTypeColors[edge.type] }"></span>
|
|
|
<span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
|
|
<span class="truncate flex-1">{{ getNodeName(edge.target) }}</span>
|
|
|
- <span class="text-base-content/40">{{ edge.score?.toFixed(2) || '-' }}</span>
|
|
|
|
|
|
|
+ <span class="text-base-content/40 shrink-0">{{ edge.score?.toFixed(2) || '-' }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
<!-- 边详情(hover 优先于 selected) -->
|
|
<!-- 边详情(hover 优先于 selected) -->
|
|
|
<template v-else-if="displayEdge">
|
|
<template v-else-if="displayEdge">
|
|
|
- <div class="flex items-center gap-2">
|
|
|
|
|
- <span class="w-4 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
|
|
|
|
|
- <span class="text-secondary font-medium">{{ displayEdge.type }} 边</span>
|
|
|
|
|
|
|
+ <div class="flex items-center gap-1.5 mb-1">
|
|
|
|
|
+ <span class="w-3 h-0.5 shrink-0" :style="{ backgroundColor: displayEdgeColor }"></span>
|
|
|
|
|
+ <span class="text-secondary font-medium text-[11px]">{{ displayEdge.type }}</span>
|
|
|
|
|
+ <span v-if="displayEdge.score !== undefined" class="text-primary text-[10px]">{{ displayEdge.score.toFixed(2) }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <div class="space-y-1.5 text-[11px]">
|
|
|
|
|
- <template v-for="(value, key) in displayEdge" :key="key">
|
|
|
|
|
- <template v-if="key !== 'index' && value !== null && value !== undefined && value !== ''">
|
|
|
|
|
- <div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
|
|
|
|
|
- <span class="text-base-content/50 shrink-0">{{ formatKey(key) }}</span>
|
|
|
|
|
- <span class="text-right break-all">{{ formatEdgeValue(key, value) }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- <div v-else-if="Object.keys(value).length > 0" class="space-y-1">
|
|
|
|
|
- <span class="text-base-content/50">{{ formatKey(key) }}</span>
|
|
|
|
|
- <div class="pl-2 border-l border-base-content/20 space-y-1">
|
|
|
|
|
- <template v-for="(v, k) in value" :key="k">
|
|
|
|
|
- <div v-if="v !== null && v !== undefined && v !== ''" class="flex justify-between gap-2 text-[10px]">
|
|
|
|
|
- <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}</span>
|
|
|
|
|
- <span class="text-right break-all">{{ formatValue(v) }}</span>
|
|
|
|
|
- </div>
|
|
|
|
|
- </template>
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <div class="space-y-0.5 text-[10px]">
|
|
|
|
|
+ <!-- 边的 detail 内容 -->
|
|
|
|
|
+ <template v-if="displayEdge.detail && Object.keys(displayEdge.detail).length > 0">
|
|
|
|
|
+ <template v-for="(v, k) in displayEdge.detail" :key="k">
|
|
|
|
|
+ <div v-if="v !== null && v !== undefined && v !== ''" class="flex gap-1">
|
|
|
|
|
+ <span class="text-base-content/50 shrink-0">{{ formatKey(k) }}:</span>
|
|
|
|
|
+ <span class="break-all">{{ formatValue(v) }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
+ <div class="border-t border-base-content/10 my-1"></div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
+ <!-- 源节点和目标节点 -->
|
|
|
|
|
+ <div class="text-[9px] space-y-0.5">
|
|
|
|
|
+ <div class="flex items-center gap-1">
|
|
|
|
|
+ <span class="text-base-content/40">源:</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="w-1.5 h-1.5 shrink-0 rounded-full"
|
|
|
|
|
+ :style="{ backgroundColor: getNodeColor(getEdgeSourceNode) }"
|
|
|
|
|
+ ></span>
|
|
|
|
|
+ <span class="break-all">{{ getEdgeSourceNode?.name || displayEdge.source }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex items-center gap-1">
|
|
|
|
|
+ <span class="text-base-content/40">目标:</span>
|
|
|
|
|
+ <span
|
|
|
|
|
+ class="w-1.5 h-1.5 shrink-0 rounded-full"
|
|
|
|
|
+ :style="{ backgroundColor: getNodeColor(getEdgeTargetNode) }"
|
|
|
|
|
+ ></span>
|
|
|
|
|
+ <span class="break-all">{{ getEdgeTargetNode?.name || displayEdge.target }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
<!-- 无选中 -->
|
|
<!-- 无选中 -->
|
|
@@ -479,8 +490,12 @@ function getTargetNodeColor(edge) {
|
|
|
return '#888'
|
|
return '#888'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 显示的节点(hover 优先于 selected)
|
|
|
|
|
-const displayNode = computed(() => store.hoverNode || store.selectedNode)
|
|
|
|
|
|
|
+// 显示的节点(hover 优先于 selected,但边 hover 时不显示节点)
|
|
|
|
|
+const displayNode = computed(() => {
|
|
|
|
|
+ // 如果正在 hover 边,不显示节点详情
|
|
|
|
|
+ if (store.hoverEdgeData) return null
|
|
|
|
|
+ return store.hoverNode || store.selectedNode
|
|
|
|
|
+})
|
|
|
|
|
|
|
|
// 显示节点的样式
|
|
// 显示节点的样式
|
|
|
const displayNodeStyle = computed(() => {
|
|
const displayNodeStyle = computed(() => {
|
|
@@ -514,6 +529,39 @@ const displayEdgeColor = computed(() => {
|
|
|
return edgeTypeColors[displayEdge.value.type] || '#888'
|
|
return edgeTypeColors[displayEdge.value.type] || '#888'
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+// 边的源节点
|
|
|
|
|
+const getEdgeSourceNode = computed(() => {
|
|
|
|
|
+ if (!displayEdge.value) return null
|
|
|
|
|
+ const sourceId = typeof displayEdge.value.source === 'object'
|
|
|
|
|
+ ? displayEdge.value.source.id
|
|
|
|
|
+ : displayEdge.value.source
|
|
|
|
|
+ return store.getNode(sourceId)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 边的目标节点
|
|
|
|
|
+const getEdgeTargetNode = computed(() => {
|
|
|
|
|
+ if (!displayEdge.value) return null
|
|
|
|
|
+ const targetId = typeof displayEdge.value.target === 'object'
|
|
|
|
|
+ ? displayEdge.value.target.id
|
|
|
|
|
+ : displayEdge.value.target
|
|
|
|
|
+ return store.getNode(targetId)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 获取节点颜色
|
|
|
|
|
+const dimensionColors = {
|
|
|
|
|
+ '灵感点': '#f39c12',
|
|
|
|
|
+ '目的点': '#3498db',
|
|
|
|
|
+ '关键点': '#9b59b6'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getNodeColor(node) {
|
|
|
|
|
+ if (!node) return '#888'
|
|
|
|
|
+ if (node.dimension && dimensionColors[node.dimension]) {
|
|
|
|
|
+ return dimensionColors[node.dimension]
|
|
|
|
|
+ }
|
|
|
|
|
+ return '#888'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 节点的入边列表(按分数降序)
|
|
// 节点的入边列表(按分数降序)
|
|
|
const nodeInEdges = computed(() => {
|
|
const nodeInEdges = computed(() => {
|
|
|
if (!displayNode.value) return []
|
|
if (!displayNode.value) return []
|
|
@@ -583,6 +631,9 @@ let mainG = null
|
|
|
let treeWidth = 0
|
|
let treeWidth = 0
|
|
|
let treeHeight = 0
|
|
let treeHeight = 0
|
|
|
|
|
|
|
|
|
|
+// 是否正在 hover 边(防止 watch 覆盖边 hover 高亮)
|
|
|
|
|
+let isEdgeHovering = false
|
|
|
|
|
+
|
|
|
// 选择帖子
|
|
// 选择帖子
|
|
|
function selectPost(index) {
|
|
function selectPost(index) {
|
|
|
selectedPostIdx.value = index
|
|
selectedPostIdx.value = index
|
|
@@ -842,6 +893,22 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
.attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
|
|
.attr('stroke-width', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeWidth)
|
|
|
.attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
|
|
.attr('stroke-dasharray', d => getEdgeStyle({ type: '匹配', score: d.score }).strokeDasharray)
|
|
|
.style('cursor', 'pointer')
|
|
.style('cursor', 'pointer')
|
|
|
|
|
+ .on('mouseenter', (e, d) => {
|
|
|
|
|
+ // hover 边,显示边详情并高亮
|
|
|
|
|
+ isEdgeHovering = true
|
|
|
|
|
+ store.setHoverEdge({
|
|
|
|
|
+ source: d.source,
|
|
|
|
|
+ target: d.target,
|
|
|
|
|
+ type: '匹配',
|
|
|
|
|
+ score: d.score
|
|
|
|
|
+ })
|
|
|
|
|
+ applyEdgeHoverHighlight(d.source, d.target)
|
|
|
|
|
+ })
|
|
|
|
|
+ .on('mouseleave', () => {
|
|
|
|
|
+ isEdgeHovering = false
|
|
|
|
|
+ store.clearHoverEdge()
|
|
|
|
|
+ clearEdgeHoverHighlight()
|
|
|
|
|
+ })
|
|
|
.on('click', (e, d) => {
|
|
.on('click', (e, d) => {
|
|
|
e.stopPropagation()
|
|
e.stopPropagation()
|
|
|
store.selectEdge({
|
|
store.selectEdge({
|
|
@@ -1099,6 +1166,23 @@ function renderWalkedLayer() {
|
|
|
.attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
|
|
.attr('stroke-width', d => getEdgeStyle({ type: d.type, score: d.score }).strokeWidth)
|
|
|
.attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
|
|
.attr('stroke-dasharray', d => getEdgeStyle({ type: d.type, score: d.score }).strokeDasharray)
|
|
|
.style('cursor', 'pointer')
|
|
.style('cursor', 'pointer')
|
|
|
|
|
+ .on('mouseenter', (e, d) => {
|
|
|
|
|
+ // hover 边,显示边详情并高亮
|
|
|
|
|
+ isEdgeHovering = true
|
|
|
|
|
+ store.setHoverEdge({
|
|
|
|
|
+ source: d.source,
|
|
|
|
|
+ target: d.target,
|
|
|
|
|
+ type: d.type,
|
|
|
|
|
+ score: d.score,
|
|
|
|
|
+ detail: d.detail
|
|
|
|
|
+ })
|
|
|
|
|
+ applyEdgeHoverHighlight(d.source, d.target)
|
|
|
|
|
+ })
|
|
|
|
|
+ .on('mouseleave', () => {
|
|
|
|
|
+ isEdgeHovering = false
|
|
|
|
|
+ store.clearHoverEdge()
|
|
|
|
|
+ clearEdgeHoverHighlight()
|
|
|
|
|
+ })
|
|
|
.on('click', (e, d) => {
|
|
.on('click', (e, d) => {
|
|
|
e.stopPropagation()
|
|
e.stopPropagation()
|
|
|
store.selectEdge({
|
|
store.selectEdge({
|
|
@@ -1530,6 +1614,55 @@ function zoomToEdge(sourceId, targetId) {
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// 边 hover 高亮(只高亮当前边和两个端点)
|
|
|
|
|
+function applyEdgeHoverHighlight(sourceId, targetId) {
|
|
|
|
|
+ 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')
|
|
|
|
|
+
|
|
|
|
|
+ // 节点:只高亮两个端点,清除其他状态
|
|
|
|
|
+ allNodes
|
|
|
|
|
+ .classed('dimmed', function(d) {
|
|
|
|
|
+ const nodeId = d.data?.id || d.id
|
|
|
|
|
+ return nodeId !== sourceId && nodeId !== targetId
|
|
|
|
|
+ })
|
|
|
|
|
+ .classed('selected', false)
|
|
|
|
|
+ .classed('locked-path', false)
|
|
|
|
|
+ .classed('highlighted', false)
|
|
|
|
|
+
|
|
|
|
|
+ // 边:只高亮当前边,清除 highlighted 状态
|
|
|
|
|
+ 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 isCurrentEdge = srcId === sourceId && tgtId === targetId
|
|
|
|
|
+ d3.select(this)
|
|
|
|
|
+ .classed('dimmed', !isCurrentEdge)
|
|
|
|
|
+ .classed('highlighted', isCurrentEdge)
|
|
|
|
|
+ .classed('locked-path', false)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 标签
|
|
|
|
|
+ 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 isCurrentEdge = srcId === sourceId && tgtId === targetId
|
|
|
|
|
+ d3.select(this)
|
|
|
|
|
+ .classed('dimmed', !isCurrentEdge)
|
|
|
|
|
+ .classed('locked-path', false)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 清除边 hover 高亮
|
|
|
|
|
+function clearEdgeHoverHighlight() {
|
|
|
|
|
+ if (!svgRef.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 恢复正常高亮状态
|
|
|
|
|
+ updateHighlight()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 更新高亮/置灰状态
|
|
// 更新高亮/置灰状态
|
|
|
function updateHighlight() {
|
|
function updateHighlight() {
|
|
|
// 使用帖子游走的边集合(如果有),否则用人设游走的边集合
|
|
// 使用帖子游走的边集合(如果有),否则用人设游走的边集合
|
|
@@ -1575,6 +1708,10 @@ watch(() => store.highlightedNodeIds.size, updateHighlight)
|
|
|
// 监听 hover 状态变化(用于左右联动)
|
|
// 监听 hover 状态变化(用于左右联动)
|
|
|
watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
|
|
watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
|
|
|
if (!svgRef.value) return
|
|
if (!svgRef.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 边 hover 时不处理,避免覆盖边 hover 高亮
|
|
|
|
|
+ if (isEdgeHovering) return
|
|
|
|
|
+
|
|
|
const svg = d3.select(svgRef.value)
|
|
const svg = d3.select(svgRef.value)
|
|
|
|
|
|
|
|
const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|
|
const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|