|
|
@@ -43,7 +43,7 @@
|
|
|
|
|
|
<!-- SVG 容器 -->
|
|
|
<div ref="containerRef" class="flex-1 overflow-hidden bg-base-100">
|
|
|
- <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick" @dblclick="handleSvgDblClick"></svg>
|
|
|
+ <svg ref="svgRef" class="w-full h-full" @click="handleSvgClick"></svg>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -350,21 +350,12 @@ let nodeElements = {}
|
|
|
let baseNodeElements = {} // 基础节点(帖子树+匹配层),不含游走节点
|
|
|
let currentRoot = null
|
|
|
|
|
|
-// 处理节点单击(触发游走)
|
|
|
+// 处理节点点击
|
|
|
function handleNodeClick(event, d) {
|
|
|
event.stopPropagation()
|
|
|
store.selectNode(d)
|
|
|
}
|
|
|
|
|
|
-// 处理节点双击(锁定hover)
|
|
|
-function handleNodeDblClick(event, d) {
|
|
|
- event.stopPropagation()
|
|
|
- const startNodeId = store.selectedNodeId
|
|
|
- if (startNodeId) {
|
|
|
- store.lockCurrentHover(startNodeId)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
// 渲染树
|
|
|
function renderTree() {
|
|
|
const svg = d3.select(svgRef.value)
|
|
|
@@ -464,7 +455,6 @@ function renderTree() {
|
|
|
.attr('transform', d => `translate(${d.x},${d.y})`)
|
|
|
.style('cursor', 'pointer')
|
|
|
.on('click', handleNodeClick)
|
|
|
- .on('dblclick', handleNodeDblClick)
|
|
|
|
|
|
// 节点形状(使用统一配置)
|
|
|
nodes.each(function(d) {
|
|
|
@@ -642,7 +632,6 @@ function renderMatchLayer(contentG, root, baseTreeHeight) {
|
|
|
.attr('transform', d => `translate(${d.x},${d.y})`)
|
|
|
.style('cursor', 'pointer')
|
|
|
.on('click', handleMatchNodeClick)
|
|
|
- .on('dblclick', handleMatchNodeDblClick)
|
|
|
|
|
|
// 匹配节点形状(使用统一配置)
|
|
|
matchNodes.each(function(d) {
|
|
|
@@ -911,13 +900,6 @@ function renderWalkedLayer() {
|
|
|
event.stopPropagation()
|
|
|
store.selectNode(d)
|
|
|
})
|
|
|
- .on('dblclick', (event, d) => {
|
|
|
- event.stopPropagation()
|
|
|
- const startNodeId = store.selectedNodeId
|
|
|
- if (startNodeId) {
|
|
|
- store.lockCurrentHover(startNodeId)
|
|
|
- }
|
|
|
- })
|
|
|
|
|
|
// 节点形状
|
|
|
walkedNodeGroups.each(function(d) {
|
|
|
@@ -949,6 +931,112 @@ function renderWalkedLayer() {
|
|
|
setupHoverHandlers()
|
|
|
}
|
|
|
|
|
|
+// 锁定按钮相关
|
|
|
+let lockButtonG = null
|
|
|
+let hideButtonTimer = null
|
|
|
+
|
|
|
+// 创建锁定按钮(在主组中创建一次)
|
|
|
+function createLockButton() {
|
|
|
+ if (!mainG) return
|
|
|
+
|
|
|
+ // 移除旧按钮
|
|
|
+ mainG.selectAll('.lock-button').remove()
|
|
|
+
|
|
|
+ // 创建按钮组
|
|
|
+ lockButtonG = mainG.append('g')
|
|
|
+ .attr('class', 'lock-button')
|
|
|
+ .style('display', 'none')
|
|
|
+ .style('cursor', 'pointer')
|
|
|
+ .on('click', (e) => {
|
|
|
+ e.stopPropagation()
|
|
|
+ const startNodeId = store.selectedNodeId
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ // 已锁定,点击解锁
|
|
|
+ store.clearLockedHover()
|
|
|
+ hideLockButton()
|
|
|
+ } else if (startNodeId) {
|
|
|
+ // 未锁定,点击锁定
|
|
|
+ store.lockCurrentHover(startNodeId)
|
|
|
+ updateLockButtonIcon(true)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .on('mouseenter', () => {
|
|
|
+ // 鼠标进入按钮,取消隐藏定时器
|
|
|
+ if (hideButtonTimer) {
|
|
|
+ clearTimeout(hideButtonTimer)
|
|
|
+ hideButtonTimer = null
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .on('mouseleave', () => {
|
|
|
+ // 鼠标离开按钮,如果未锁定则隐藏
|
|
|
+ if (!store.lockedHoverNodeId) {
|
|
|
+ hideLockButton()
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 按钮背景
|
|
|
+ lockButtonG.append('circle')
|
|
|
+ .attr('r', 10)
|
|
|
+ .attr('fill', '#2d3748')
|
|
|
+ .attr('stroke', '#4a5568')
|
|
|
+ .attr('stroke-width', 1)
|
|
|
+
|
|
|
+ // 锁定图标
|
|
|
+ lockButtonG.append('text')
|
|
|
+ .attr('class', 'lock-icon')
|
|
|
+ .attr('text-anchor', 'middle')
|
|
|
+ .attr('dy', '0.35em')
|
|
|
+ .attr('font-size', '10px')
|
|
|
+ .attr('fill', '#fff')
|
|
|
+ .text('📌')
|
|
|
+}
|
|
|
+
|
|
|
+// 更新锁定按钮图标
|
|
|
+function updateLockButtonIcon(isLocked) {
|
|
|
+ if (!lockButtonG) return
|
|
|
+ lockButtonG.select('.lock-icon').text(isLocked ? '🔓' : '📌')
|
|
|
+}
|
|
|
+
|
|
|
+// 显示锁定按钮
|
|
|
+function showLockButton(x, y) {
|
|
|
+ if (!lockButtonG) return
|
|
|
+
|
|
|
+ // 取消隐藏定时器
|
|
|
+ if (hideButtonTimer) {
|
|
|
+ clearTimeout(hideButtonTimer)
|
|
|
+ hideButtonTimer = null
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新图标状态
|
|
|
+ updateLockButtonIcon(!!store.lockedHoverNodeId)
|
|
|
+
|
|
|
+ lockButtonG
|
|
|
+ .attr('transform', `translate(${x + 15}, ${y - 15})`)
|
|
|
+ .style('display', 'block')
|
|
|
+}
|
|
|
+
|
|
|
+// 隐藏锁定按钮
|
|
|
+function hideLockButton() {
|
|
|
+ if (!lockButtonG) return
|
|
|
+ lockButtonG.style('display', 'none')
|
|
|
+}
|
|
|
+
|
|
|
+// 延迟隐藏锁定按钮(给用户移动到按钮的时间)
|
|
|
+function delayHideLockButton() {
|
|
|
+ // 如果已锁定,不隐藏
|
|
|
+ if (store.lockedHoverNodeId) return
|
|
|
+
|
|
|
+ if (hideButtonTimer) {
|
|
|
+ clearTimeout(hideButtonTimer)
|
|
|
+ }
|
|
|
+ hideButtonTimer = setTimeout(() => {
|
|
|
+ if (!store.lockedHoverNodeId) {
|
|
|
+ hideLockButton()
|
|
|
+ }
|
|
|
+ hideButtonTimer = null
|
|
|
+ }, 200)
|
|
|
+}
|
|
|
+
|
|
|
// 设置 hover 处理器(在所有元素创建后调用)
|
|
|
function setupHoverHandlers() {
|
|
|
if (!svgRef.value || !store.selectedNodeId) return
|
|
|
@@ -957,32 +1045,52 @@ function setupHoverHandlers() {
|
|
|
const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
|
|
|
const startNodeId = store.selectedNodeId
|
|
|
|
|
|
+ // 创建锁定按钮
|
|
|
+ createLockButton()
|
|
|
+
|
|
|
+ // 如果已有锁定状态,显示解锁按钮
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
|
|
|
+ if (lockedNodeInfo) {
|
|
|
+ showLockButton(lockedNodeInfo.x - 50, lockedNodeInfo.y - 25)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// 添加 hover 处理器(路径计算由 store 统一处理)
|
|
|
allNodes
|
|
|
.on('mouseenter', (event, d) => {
|
|
|
const nodeId = d.data?.id || d.id
|
|
|
+ // 排除起始节点
|
|
|
+ if (nodeId === startNodeId) return
|
|
|
+
|
|
|
store.computeHoverPath(startNodeId, nodeId, 'post-tree')
|
|
|
+
|
|
|
+ // 显示锁定按钮
|
|
|
+ // 如果已锁定,按钮保持在锁定节点位置;否则跟随hover节点
|
|
|
+ if (store.lockedHoverNodeId) {
|
|
|
+ const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
|
|
|
+ if (lockedNodeInfo) {
|
|
|
+ showLockButton(lockedNodeInfo.x - 50, lockedNodeInfo.y - 25)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const nodeInfo = nodeElements[nodeId]
|
|
|
+ if (nodeInfo && store.hoverPathNodes.size > 0) {
|
|
|
+ showLockButton(nodeInfo.x - 50, nodeInfo.y - 25)
|
|
|
+ }
|
|
|
+ }
|
|
|
})
|
|
|
.on('mouseleave', () => {
|
|
|
store.clearHover()
|
|
|
+ delayHideLockButton()
|
|
|
})
|
|
|
}
|
|
|
|
|
|
-// 匹配节点单击处理(触发游走)
|
|
|
+// 匹配节点点击处理
|
|
|
function handleMatchNodeClick(event, d) {
|
|
|
event.stopPropagation()
|
|
|
store.selectNode(d)
|
|
|
}
|
|
|
|
|
|
-// 匹配节点双击处理(锁定hover)
|
|
|
-function handleMatchNodeDblClick(event, d) {
|
|
|
- event.stopPropagation()
|
|
|
- const startNodeId = store.selectedNodeId
|
|
|
- if (startNodeId) {
|
|
|
- store.lockCurrentHover(startNodeId)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
// ========== 详情显示格式化函数 ==========
|
|
|
|
|
|
// 格式化字段名(camelCase/snake_case -> 中文/可读)
|
|
|
@@ -1130,7 +1238,7 @@ function updateHighlight() {
|
|
|
applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
|
|
|
}
|
|
|
|
|
|
-// 单击空白取消游走
|
|
|
+// 点击空白取消
|
|
|
function handleSvgClick(event) {
|
|
|
const target = event.target
|
|
|
if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
|
|
|
@@ -1138,14 +1246,6 @@ function handleSvgClick(event) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 双击空白取消hover锁定
|
|
|
-function handleSvgDblClick(event) {
|
|
|
- const target = event.target
|
|
|
- if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
|
|
|
- store.clearLockedHover()
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
// 监听选中/高亮变化,统一更新
|
|
|
watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
|
|
|
updateHighlight()
|