|
@@ -101,7 +101,7 @@
|
|
|
</div>
|
|
</div>
|
|
|
<div class="flex-1 overflow-y-auto p-3 space-y-3">
|
|
<div class="flex-1 overflow-y-auto p-3 space-y-3">
|
|
|
<!-- 原始JSON模式 -->
|
|
<!-- 原始JSON模式 -->
|
|
|
- <template v-if="showRawData && (store.selectedNode || store.selectedEdge)">
|
|
|
|
|
|
|
+ <template v-if="showRawData && (displayNode || store.selectedEdge)">
|
|
|
<div class="relative">
|
|
<div class="relative">
|
|
|
<button
|
|
<button
|
|
|
@click="copyJson"
|
|
@click="copyJson"
|
|
@@ -111,26 +111,28 @@
|
|
|
<span v-if="copySuccess">✓</span>
|
|
<span v-if="copySuccess">✓</span>
|
|
|
<span v-else>📋</span>
|
|
<span v-else>📋</span>
|
|
|
</button>
|
|
</button>
|
|
|
- <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(store.selectedNode || store.selectedEdge, null, 2) }}</pre>
|
|
|
|
|
|
|
+ <pre class="text-[10px] bg-base-100 p-2 pr-8 rounded overflow-x-auto whitespace-pre-wrap break-all select-all">{{ JSON.stringify(displayNode || store.selectedEdge, null, 2) }}</pre>
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
<!-- 渲染模式 -->
|
|
<!-- 渲染模式 -->
|
|
|
<template v-else-if="!showRawData">
|
|
<template v-else-if="!showRawData">
|
|
|
- <!-- 节点详情 -->
|
|
|
|
|
- <template v-if="store.selectedNode">
|
|
|
|
|
|
|
+ <!-- 节点详情(hover 优先于 selected) -->
|
|
|
|
|
+ <template v-if="displayNode">
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
+ <!-- hover 标识 -->
|
|
|
|
|
+ <span v-if="store.hoverNode" class="text-[10px] text-warning/60">[hover]</span>
|
|
|
<!-- 节点样式:空心(帖子域)或实心(人设域) -->
|
|
<!-- 节点样式:空心(帖子域)或实心(人设域) -->
|
|
|
<span
|
|
<span
|
|
|
class="w-2.5 h-2.5 shrink-0"
|
|
class="w-2.5 h-2.5 shrink-0"
|
|
|
- :class="selectedNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
|
|
|
|
|
- :style="selectedNodeStyle.hollow
|
|
|
|
|
- ? { backgroundColor: 'transparent', border: '2px solid ' + selectedNodeStyle.color }
|
|
|
|
|
- : { backgroundColor: selectedNodeStyle.color }"
|
|
|
|
|
|
|
+ :class="displayNodeStyle.shape === 'rect' ? 'rounded-sm' : 'rounded-full'"
|
|
|
|
|
+ :style="displayNodeStyle.hollow
|
|
|
|
|
+ ? { backgroundColor: 'transparent', border: '2px solid ' + displayNodeStyle.color }
|
|
|
|
|
+ : { backgroundColor: displayNodeStyle.color }"
|
|
|
></span>
|
|
></span>
|
|
|
- <span class="text-primary font-medium truncate">{{ store.selectedNode.name }}</span>
|
|
|
|
|
|
|
+ <span class="text-primary font-medium truncate">{{ displayNode.name }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="space-y-1.5 text-[11px]">
|
|
<div class="space-y-1.5 text-[11px]">
|
|
|
- <template v-for="(value, key) in store.selectedNode" :key="key">
|
|
|
|
|
|
|
+ <template v-for="(value, key) in displayNode" :key="key">
|
|
|
<template v-if="key !== 'name' && value !== null && value !== undefined && value !== ''">
|
|
<template v-if="key !== 'name' && value !== null && value !== undefined && value !== ''">
|
|
|
<div v-if="typeof value !== 'object'" class="flex justify-between gap-2">
|
|
<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-base-content/50 shrink-0">{{ formatKey(key) }}</span>
|
|
@@ -278,7 +280,16 @@ function getTargetNodeColor(edge) {
|
|
|
return '#888'
|
|
return '#888'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 选中节点的样式
|
|
|
|
|
|
|
+// 显示的节点(hover 优先于 selected)
|
|
|
|
|
+const displayNode = computed(() => store.hoverNode || store.selectedNode)
|
|
|
|
|
+
|
|
|
|
|
+// 显示节点的样式
|
|
|
|
|
+const displayNodeStyle = computed(() => {
|
|
|
|
|
+ if (!displayNode.value) return { color: '#888', shape: 'circle', hollow: false }
|
|
|
|
|
+ return getNodeStyle(displayNode.value)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// 选中节点的样式(兼容旧代码)
|
|
|
const selectedNodeStyle = computed(() => {
|
|
const selectedNodeStyle = computed(() => {
|
|
|
if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
|
|
if (!store.selectedNode) return { color: '#888', shape: 'circle', hollow: false }
|
|
|
return getNodeStyle(store.selectedNode)
|
|
return getNodeStyle(store.selectedNode)
|
|
@@ -932,7 +943,7 @@ function setupHoverHandlers() {
|
|
|
allNodes
|
|
allNodes
|
|
|
.on('mouseenter', (event, d) => {
|
|
.on('mouseenter', (event, d) => {
|
|
|
const nodeId = d.data?.id || d.id
|
|
const nodeId = d.data?.id || d.id
|
|
|
- store.computeHoverPath(startNodeId, nodeId)
|
|
|
|
|
|
|
+ store.computeHoverPath(startNodeId, nodeId, 'post-tree')
|
|
|
})
|
|
})
|
|
|
.on('mouseleave', () => {
|
|
.on('mouseleave', () => {
|
|
|
store.clearHover()
|
|
store.clearHover()
|
|
@@ -1135,12 +1146,58 @@ watch(() => store.hoverPathNodes.size, () => {
|
|
|
if (store.hoverPathNodes.size > 0) {
|
|
if (store.hoverPathNodes.size > 0) {
|
|
|
// 应用 hover 高亮
|
|
// 应用 hover 高亮
|
|
|
applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes)
|
|
applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes)
|
|
|
|
|
+
|
|
|
|
|
+ // 如果是从 GraphView 触发的,缩放到显示完整路径
|
|
|
|
|
+ if (store.hoverSource === 'graph') {
|
|
|
|
|
+ zoomToPathNodes(store.hoverPathNodes)
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
// 清除 hover,恢复原有高亮
|
|
// 清除 hover,恢复原有高亮
|
|
|
updateHighlight()
|
|
updateHighlight()
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+// 缩放到显示路径上的所有节点
|
|
|
|
|
+function zoomToPathNodes(pathNodes) {
|
|
|
|
|
+ if (!zoom || !containerRef.value || !svgRef.value) return
|
|
|
|
|
+
|
|
|
|
|
+ // 收集路径节点的位置
|
|
|
|
|
+ const positions = []
|
|
|
|
|
+ for (const nodeId of pathNodes) {
|
|
|
|
|
+ const nodeInfo = nodeElements[nodeId]
|
|
|
|
|
+ if (nodeInfo) {
|
|
|
|
|
+ positions.push({ x: nodeInfo.x, y: nodeInfo.y })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (positions.length === 0) return
|
|
|
|
|
+
|
|
|
|
|
+ // 计算边界框
|
|
|
|
|
+ const minX = Math.min(...positions.map(p => p.x))
|
|
|
|
|
+ const maxX = Math.max(...positions.map(p => p.x))
|
|
|
|
|
+ const minY = Math.min(...positions.map(p => p.y))
|
|
|
|
|
+ const maxY = Math.max(...positions.map(p => p.y))
|
|
|
|
|
+
|
|
|
|
|
+ const width = containerRef.value.clientWidth
|
|
|
|
|
+ const height = containerRef.value.clientHeight
|
|
|
|
|
+ const padding = 60
|
|
|
|
|
+
|
|
|
|
|
+ // 计算需要的缩放和平移
|
|
|
|
|
+ const boxWidth = maxX - minX + padding * 2
|
|
|
|
|
+ const boxHeight = maxY - minY + padding * 2
|
|
|
|
|
+ const scale = Math.min(width / boxWidth, height / boxHeight, 1.5)
|
|
|
|
|
+ const centerX = (minX + maxX) / 2
|
|
|
|
|
+ const centerY = (minY + maxY) / 2
|
|
|
|
|
+ const translateX = width / 2 - centerX * scale
|
|
|
|
|
+ const translateY = height / 2 - centerY * scale
|
|
|
|
|
+
|
|
|
|
|
+ const svg = d3.select(svgRef.value)
|
|
|
|
|
+ svg.transition().duration(200).call(
|
|
|
|
|
+ zoom.transform,
|
|
|
|
|
+ d3.zoomIdentity.translate(translateX, translateY).scale(scale)
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// 监听帖子游走结果变化,渲染游走层
|
|
// 监听帖子游走结果变化,渲染游走层
|
|
|
watch(() => store.postWalkedNodes.length, () => {
|
|
watch(() => store.postWalkedNodes.length, () => {
|
|
|
nextTick(renderWalkedLayer)
|
|
nextTick(renderWalkedLayer)
|