// 统一的高亮/置灰工具 import * as d3 from 'd3' /** * 获取节点ID(兼容 d3.hierarchy 和普通对象) */ export function getNodeId(d) { return d.data?.id || d.id } /** * 获取边的 source 和 target ID */ export function getLinkIds(link) { const srcId = link.source?.id || link.source?.data?.id || link.source const tgtId = link.target?.id || link.target?.data?.id || link.target return { srcId, tgtId } } /** * 检查边是否在游走的边集合中 */ function isEdgeInWalked(d, walkedEdgeSet) { if (!walkedEdgeSet) return false const { srcId, tgtId } = getLinkIds(d) return walkedEdgeSet.has(`${srcId}->${tgtId}`) || walkedEdgeSet.has(`${tgtId}->${srcId}`) } /** * BFS 找从 start 到 end 的路径,返回路径上所有节点 ID 的 Set * @param {string} startId - 起点节点ID * @param {string} endId - 终点节点ID * @param {Array} links - 边数组,每个元素需要有 source 和 target */ export function findPath(startId, endId, links) { // 构建邻接表(无向) const adj = new Map() for (const link of links) { const { srcId, tgtId } = getLinkIds(link) if (!adj.has(srcId)) adj.set(srcId, []) if (!adj.has(tgtId)) adj.set(tgtId, []) adj.get(srcId).push(tgtId) adj.get(tgtId).push(srcId) } // BFS const visited = new Set([startId]) const parent = new Map() const queue = [startId] while (queue.length > 0) { const curr = queue.shift() if (curr === endId) break for (const neighbor of (adj.get(curr) || [])) { if (!visited.has(neighbor)) { visited.add(neighbor) parent.set(neighbor, curr) queue.push(neighbor) } } } // 回溯路径 const pathNodes = new Set() if (visited.has(endId)) { let curr = endId while (curr) { pathNodes.add(curr) curr = parent.get(curr) } } return pathNodes } /** * 应用 hover 效果:高亮从起点到 hover 节点的路径 * 支持嵌套:锁定路径 + 新 hover 路径同时显示 * @param {D3Selection} nodeSelection - 节点选择集 * @param {D3Selection} linkSelection - 边选择集 * @param {D3Selection} labelSelection - 标签选择集(可选) * @param {Set} pathNodes - 当前 hover 路径上的节点ID集合 * @param {Set} lockedPathNodes - 锁定路径上的节点ID集合(可选) */ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection, pathNodes, lockedPathNodes = null) { // 合并路径:锁定 + hover const allPathNodes = new Set([...pathNodes]) if (lockedPathNodes) { for (const id of lockedPathNodes) { allPathNodes.add(id) } } // 节点:不在任何路径中的置灰,只在锁定路径中的半透明 nodeSelection .classed('dimmed', d => !allPathNodes.has(getNodeId(d))) .classed('locked-path', d => { const id = getNodeId(d) return lockedPathNodes && lockedPathNodes.has(id) && !pathNodes.has(id) }) // 边:不在任何路径中的置灰,只在锁定路径中的半透明 linkSelection .classed('dimmed', l => { const { srcId, tgtId } = getLinkIds(l) return !allPathNodes.has(srcId) || !allPathNodes.has(tgtId) }) .classed('locked-path', l => { if (!lockedPathNodes) return false const { srcId, tgtId } = getLinkIds(l) const inLocked = lockedPathNodes.has(srcId) && lockedPathNodes.has(tgtId) const inHover = pathNodes.has(srcId) && pathNodes.has(tgtId) return inLocked && !inHover }) if (labelSelection) { labelSelection .classed('dimmed', l => { const { srcId, tgtId } = getLinkIds(l) return !allPathNodes.has(srcId) || !allPathNodes.has(tgtId) }) .classed('locked-path', l => { if (!lockedPathNodes) return false const { srcId, tgtId } = getLinkIds(l) const inLocked = lockedPathNodes.has(srcId) && lockedPathNodes.has(tgtId) const inHover = pathNodes.has(srcId) && pathNodes.has(tgtId) return inLocked && !inHover }) } } /** * 清除 hover 效果 */ export function clearHoverHighlight(nodeSelection, linkSelection, labelSelection) { nodeSelection.classed('dimmed', false).classed('locked-path', false) linkSelection.classed('dimmed', false).classed('locked-path', false) if (labelSelection) { labelSelection.classed('dimmed', false).classed('locked-path', false) } } /** * 应用高亮/置灰效果 * @param {SVGElement} svgEl - SVG DOM 元素 * @param {Set} highlightedIds - 高亮的节点ID集合 * @param {Set} walkedEdgeSet - 游走的边集合(格式:"sourceId->targetId") * @param {string} selectedId - 选中的节点ID */ export function applyHighlight(svgEl, highlightedIds, walkedEdgeSet = null, selectedId = null) { if (!svgEl) return const svg = d3.select(svgEl) const hasHighlight = highlightedIds.size > 0 // 所有节点:在 highlightedIds 中的保持,否则置灰 svg.selectAll('.tree-node, .match-node, .graph-node, .walked-node') .classed('selected', d => { const nodeId = getNodeId(d) if (selectedId) { return nodeId === selectedId } else if (hasHighlight) { return highlightedIds.has(nodeId) } return false }) .classed('dimmed', d => hasHighlight && !highlightedIds.has(getNodeId(d))) // 所有边:在 walkedEdgeSet 中的保持,否则置灰 svg.selectAll('.tree-link, .graph-link, .graph-link-label, .match-link, .match-score, .walked-link, .walked-score') .classed('dimmed', function(d) { if (!hasHighlight) return false if (d && walkedEdgeSet) { return !isEdgeInWalked(d, walkedEdgeSet) } if (d) { const { srcId, tgtId } = getLinkIds(d) return !highlightedIds.has(srcId) || !highlightedIds.has(tgtId) } return true }) // 选中节点的边框 svg.selectAll('.match-node').each(function(d) { const isSelected = d.id === selectedId d3.select(this).select('.tree-shape') .attr('stroke', isSelected ? '#fff' : 'rgba(255,255,255,0.5)') .attr('stroke-width', isSelected ? 2 : 1) }) }