Browse Source

refactor: 重构边高亮逻辑,优化帖子标签点击效果

- 提取collectEdgeHighlightData函数用于收集单条边的高亮数据
- 提取applyEdgeHighlight函数统一处理高亮逻辑
- 简化highlightEdge函数复用上述两个函数
- 重构handlePostTagClick实现点击帖子标签等价于点击所有直连层内边的并集效果

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 4 days ago
parent
commit
9265a0d423
1 changed files with 410 additions and 99 deletions
  1. 410 99
      script/data_processing/visualize_match_graph.py

+ 410 - 99
script/data_processing/visualize_match_graph.py

@@ -2187,11 +2187,97 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .style("cursor", "pointer")
                     .on("click", (event, d) => {{
                         event.stopPropagation();
-                        // 复用共享的节点点击处理函数
-                        handleNodeClick(d.data.id || d.data.节点ID, d.data.name || d.data.节点名称);
+                        const nodeId = d.data.id || d.data.节点ID;
+                        const nodeName = d.data.name || d.data.节点名称;
+                        // 帖子标签节点使用专门的处理函数
+                        if (d.data.nodeType === "标签") {{
+                            handlePostTagClick(nodeId, nodeName);
+                        }} else {{
+                            handleNodeClick(nodeId, nodeName);
+                        }}
                     }});
 
                 // 节点形状
+                // 帖子标签节点点击处理函数(激活所有直连的层内边,效果等价于点击N条层内边的并集)
+                function handlePostTagClick(tagNodeId, tagNodeName) {{
+                    if (!tagNodeId) return;
+
+                    // 层内边类型
+                    const intraLayerEdgeTypes = [
+                        "标签共现", "分类共现(跨点)", "分类共现(点内)",
+                        "镜像_标签共现", "镜像_分类共现(跨点)", "镜像_分类共现(点内)",
+                        "二阶_标签共现", "二阶_分类共现(跨点)", "二阶_分类共现(点内)"
+                    ];
+
+                    // 找到所有与该标签直连的层内边及其索引
+                    const connectedIntraEdges = [];
+                    links.forEach((link, index) => {{
+                        const srcId = typeof link.source === "object" ? link.source.id : link.source;
+                        const tgtId = typeof link.target === "object" ? link.target.id : link.target;
+                        if ((srcId === tagNodeId || tgtId === tagNodeId) && intraLayerEdgeTypes.includes(link.type)) {{
+                            connectedIntraEdges.push({{ link, index }});
+                        }}
+                    }});
+
+                    console.log("帖子标签直连的层内边数:", connectedIntraEdges.length);
+
+                    if (connectedIntraEdges.length === 0) {{
+                        // 没有层内边,回退到普通处理
+                        handleNodeClick(tagNodeId, tagNodeName);
+                        return;
+                    }}
+
+                    // 对每条边调用collectEdgeHighlightData,然后合并结果
+                    const mergedNodeIds = new Set();
+                    const mergedLinkIndices = new Set();
+                    const mergedPathNodes = new Set();
+
+                    connectedIntraEdges.forEach(({{ link, index }}) => {{
+                        const {{ nodeIds, linkIndices, pathNodes }} = collectEdgeHighlightData(link, index);
+                        nodeIds.forEach(id => mergedNodeIds.add(id));
+                        linkIndices.forEach(i => mergedLinkIndices.add(i));
+                        pathNodes.forEach(n => mergedPathNodes.add(n));
+                    }});
+
+                    // 复用applyEdgeHighlight进行高亮(处理左边圆和右边树)
+                    applyEdgeHighlight(mergedNodeIds, mergedLinkIndices);
+
+                    // 在人设树中高亮所有相关路径节点(过滤掉帖子节点)
+                    const personaPathNodes = Array.from(mergedPathNodes).filter(id => !id.startsWith("帖子_"));
+                    if (personaPathNodes.length > 0) {{
+                        // 获取边类型统计作为显示
+                        const edgeTypeCount = {{}};
+                        connectedIntraEdges.forEach(e => {{
+                            const t = e.link.type.replace("镜像_", "").replace("二阶_", "");
+                            edgeTypeCount[t] = (edgeTypeCount[t] || 0) + 1;
+                        }});
+                        const typeStr = Object.entries(edgeTypeCount).map(([t, c]) => `${{t}}×${{c}}`).join(", ");
+                        syncTreeAndRelationGraph(personaPathNodes, typeStr);
+                    }}
+
+                    // 显示节点详情
+                    const panel = document.getElementById("detailPanel");
+                    panel.classList.add("active");
+                    document.getElementById("detailTitle").textContent = "📌 帖子标签";
+                    let html = `
+                        <p><span class="label">标签:</span> <strong>${{tagNodeName}}</strong></p>
+                        <p><span class="label">层内边数:</span> ${{connectedIntraEdges.length}}</p>
+                        <h4 style="margin-top:12px;color:#f39c12;font-size:12px;">🔗 层内关联</h4>
+                        <div class="edge-list">
+                    `;
+                    // 按边类型分组统计
+                    const edgeTypeCount = {{}};
+                    connectedIntraEdges.forEach(e => {{
+                        const t = e.link.type.replace("镜像_", "").replace("二阶_", "");
+                        edgeTypeCount[t] = (edgeTypeCount[t] || 0) + 1;
+                    }});
+                    for (const [type, count] of Object.entries(edgeTypeCount)) {{
+                        html += `<div class="edge-type-item"><span class="edge-type">${{type}}</span><span class="edge-count">${{count}}</span></div>`;
+                    }}
+                    html += `</div>`;
+                    document.getElementById("detailContent").innerHTML = html;
+                }}
+
                 treeNodes.each(function(d) {{
                     const node = d3.select(this);
                     const fill = d.data.dimColor || "#888";
@@ -2505,7 +2591,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     allGraphNodePositions[id] = personaNodePositions[id];
                 }});
 
-                // 边颜色配置
+                // 边颜色配置(包括镜像边和二阶边)
                 const graphEdgeColors = {{
                     "匹配_相同": "#e94560",
                     "匹配_相似": "#e94560",
@@ -2513,7 +2599,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     "包含": "#3498db",
                     "分类共现(跨点)": "#2ecc71",
                     "分类共现(点内)": "#2ecc71",
-                    "标签共现": "#f39c12"
+                    "标签共现": "#f39c12",
+                    // 镜像边(与原始边颜色相同)
+                    "镜像_分类共现(跨点)": "#2ecc71",
+                    "镜像_分类共现(点内)": "#2ecc71",
+                    "镜像_标签共现": "#f39c12",
+                    // 二阶边(与原始边颜色相同)
+                    "二阶_分类共现(跨点)": "#2ecc71",
+                    "二阶_分类共现(点内)": "#2ecc71",
+                    "二阶_标签共现": "#f39c12"
                 }};
 
                 // 展开边数据:如果标签有多个位置,为每个位置创建一条边
@@ -2629,7 +2723,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     return `M${{src.x}},${{src.y}} Q${{midX}},${{midY}} ${{tgt.x}},${{tgt.y}}`;
                 }};
 
-                // 可见的跨层边
+                // 可见的跨层边(不可交互)
                 graphLinks.append("path")
                     .attr("class", "graph-link")
                     .attr("d", graphLinkPath)
@@ -2637,61 +2731,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .attr("stroke", d => graphEdgeColors[d.type] || "#9b59b6")
                     .attr("stroke-width", 1.5)
                     .attr("stroke-opacity", 0.6)
-                    .attr("stroke-dasharray", d => d.type === "匹配_相似" ? "4,2" : "none")
-                    .style("pointer-events", "none");  // 让事件穿透到热区
+                    .attr("stroke-dasharray", d => d.type === "匹配_相似" ? "4,2" : "none");
 
-                // 跨层边热区(方便点击)
-                graphLinks.append("path")
-                    .attr("class", "graph-link-hitarea")
-                    .attr("d", graphLinkPath)
-                    .attr("fill", "none")
-                    .attr("stroke", "transparent")
-                    .attr("stroke-width", 15)
-                    .style("cursor", "pointer")
-                    .on("mouseover", function(event, d) {{
-                        // 高亮对应的可见边
-                        d3.select(this.parentNode).select(".graph-link")
-                            .attr("stroke-width", 3)
-                            .attr("stroke-opacity", 1);
-                    }})
-                    .on("mouseout", function(event, d) {{
-                        // 恢复边样式
-                        d3.select(this.parentNode).select(".graph-link")
-                            .attr("stroke-width", 1.5)
-                            .attr("stroke-opacity", 0.6);
-                    }})
-                    .on("click", function(event, d) {{
-                        event.stopPropagation();
-                        // 获取边两端节点ID
-                        const srcId = d.source ? (d.source.id || d.source) : null;
-                        const tgtId = d.target ? (d.target.id || d.target) : null;
-                        if (!srcId || !tgtId) return;
-
-                        // 在左边圆中找对应的节点,触发点击
-                        const leftNode = nodes.find(n => n.id === srcId || n.id === tgtId);
-                        if (leftNode) {{
-                            highlightNode(leftNode);
-                            showNodeInfo(leftNode);
-                            if (leftNode.source === "人设") {{
-                                handleNodeClick(leftNode.id, leftNode.节点名称);
-                            }} else {{
-                                // 帖子标签节点
-                                const highlightedNodeIds = new Set([srcId, tgtId]);
-                                const highlightedEdgeKeys = new Set([`${{srcId}}|${{tgtId}}`, `${{tgtId}}|${{srcId}}`]);
-                                g.selectAll(".link-group").each(function(link) {{
-                                    const lSrcId = typeof link.source === "object" ? link.source.id : link.source;
-                                    const lTgtId = typeof link.target === "object" ? link.target.id : link.target;
-                                    if (highlightedNodeIds.has(lSrcId) || highlightedNodeIds.has(lTgtId)) {{
-                                        highlightedNodeIds.add(lSrcId);
-                                        highlightedNodeIds.add(lTgtId);
-                                        highlightedEdgeKeys.add(`${{lSrcId}}|${{lTgtId}}`);
-                                        highlightedEdgeKeys.add(`${{lTgtId}}|${{lSrcId}}`);
-                                    }}
-                                }});
-                                highlightRightTree(highlightedNodeIds, highlightedEdgeKeys);
-                            }}
-                        }}
-                    }});
+                // 跨层边设为不可交互
+                graphLinks.style("pointer-events", "none");
 
                 // 在有分数的边上显示分数(统一逻辑)
                 graphLinks.filter(d => d.score !== null && d.score !== undefined)
@@ -2763,6 +2806,259 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 window.graphNodesRef = graphNodes;
                 window.allGraphNodePositions = allGraphNodePositions;
 
+                // ===== 层内边:同层节点之间的关系边 =====
+                // 筛选层内边(包括原始边、镜像边、二阶边)
+                const intraLayerEdgeTypes = [
+                    "标签共现", "分类共现(跨点)", "分类共现(点内)",
+                    "镜像_标签共现", "镜像_分类共现(跨点)", "镜像_分类共现(点内)",
+                    "二阶_标签共现", "二阶_分类共现(跨点)", "二阶_分类共现(点内)"
+                ];
+                const intraLayerLinks = links.filter(link => {{
+                    const srcLayer = getNodeLayer(link.source);
+                    const tgtLayer = getNodeLayer(link.target);
+                    // 同层且是关系类边
+                    return srcLayer === tgtLayer && intraLayerEdgeTypes.includes(link.type);
+                }});
+
+                console.log("层内边数:", intraLayerLinks.length);
+
+                // 展开层内边数据(处理多位置标签)
+                const expandedIntraLinks = [];
+                intraLayerLinks.forEach(link => {{
+                    const srcId = link.source.id;
+                    const tgtId = link.target.id;
+                    const srcLayer = getNodeLayer(link.source);
+
+                    // 帖子标签层(层0)- 可能有多个位置
+                    if (srcLayer === 0) {{
+                        const srcPositions = tagNodePositions[srcId] || [];
+                        const tgtPositions = tagNodePositions[tgtId] || [];
+                        // 为每对位置创建一条边
+                        srcPositions.forEach((srcPos, si) => {{
+                            tgtPositions.forEach((tgtPos, ti) => {{
+                                expandedIntraLinks.push({{
+                                    ...link,
+                                    srcNode: srcPos,
+                                    tgtNode: tgtPos,
+                                    key: `intra_${{srcId}}_${{si}}_${{tgtId}}_${{ti}}`
+                                }});
+                            }});
+                        }});
+                    }}
+                    // 人设层(层1、2)
+                    else {{
+                        const srcNode = personaNodePositions[srcId];
+                        const tgtNode = personaNodePositions[tgtId];
+                        if (srcNode && tgtNode) {{
+                            expandedIntraLinks.push({{
+                                ...link,
+                                srcNode: srcNode,
+                                tgtNode: tgtNode,
+                                key: `intra_${{srcId}}_${{tgtId}}`
+                            }});
+                        }}
+                    }}
+                }});
+
+                // 去重层内边
+                const seenIntraEdges = new Set();
+                const dedupedIntraLinks = expandedIntraLinks.filter(link => {{
+                    const srcId = link.source.id || link.source;
+                    const tgtId = link.target.id || link.target;
+                    const edgeKey = [srcId, tgtId].sort().join("|") + "|" + link.type;
+                    if (seenIntraEdges.has(edgeKey)) return false;
+                    seenIntraEdges.add(edgeKey);
+                    return true;
+                }});
+
+                console.log("去重后层内边数:", dedupedIntraLinks.length);
+
+                // 为层内边计算偏移(避免重叠)
+                // 按源节点分组,给每条边分配不同的弧度
+                const intraEdgesBySource = {{}};
+                dedupedIntraLinks.forEach((link, idx) => {{
+                    const srcId = link.source.id || link.source;
+                    if (!intraEdgesBySource[srcId]) intraEdgesBySource[srcId] = [];
+                    intraEdgesBySource[srcId].push({{ link, idx }});
+                }});
+
+                // 为每条边分配偏移索引
+                dedupedIntraLinks.forEach((link, idx) => {{
+                    const srcId = link.source.id || link.source;
+                    const group = intraEdgesBySource[srcId];
+                    const indexInGroup = group.findIndex(item => item.idx === idx);
+                    link._arcIndex = indexInGroup;
+                    link._arcTotal = group.length;
+                }});
+
+                // 绘制层内边(默认隐藏)
+                const intraLinksGroup = g.append("g").attr("class", "intra-layer-links");
+                const intraLinks = intraLinksGroup.selectAll(".intra-link-group")
+                    .data(dedupedIntraLinks)
+                    .enter()
+                    .append("g")
+                    .attr("class", "intra-link-group")
+                    .style("display", "none");  // 默认隐藏
+
+                // 层内边路径生成函数(弧线统一向上,弧度递增避免重叠)
+                const intraLinkPath = d => {{
+                    const src = d.srcNode;
+                    const tgt = d.tgtNode;
+                    if (!src || !tgt) return "";
+
+                    const dx = tgt.x - src.x;
+                    const dy = tgt.y - src.y;
+                    const dist = Math.sqrt(dx * dx + dy * dy);
+
+                    // 根据边的索引计算弧度(统一向上)
+                    const baseArc = Math.min(dist * 0.2, 30);
+                    const arcStep = 20;  // 每条边的弧度增量
+                    const arcIndex = d._arcIndex || 0;
+                    const arcHeight = baseArc + arcIndex * arcStep;
+
+                    const midX = (src.x + tgt.x) / 2;
+                    const midY = (src.y + tgt.y) / 2;
+
+                    // 统一向上弯曲(Y减小)
+                    return `M${{src.x}},${{src.y}} Q${{midX}},${{midY - arcHeight}} ${{tgt.x}},${{tgt.y}}`;
+                }};
+
+                // 层内边虚线配置(与左边一致)
+                const intraEdgeDash = {{
+                    "匹配_相似": "5,5",
+                    "包含": "2,2",
+                    "镜像_包含": "2,2"
+                }};
+
+                // 层内边可见路径(样式与左边圆形图一致)
+                intraLinks.append("path")
+                    .attr("class", d => "intra-link " + getEdgeClass(d.type))
+                    .attr("d", intraLinkPath)
+                    .attr("fill", "none")
+                    .attr("stroke", d => graphEdgeColors[d.type] || "#f39c12")
+                    .attr("stroke-width", 2)
+                    .attr("stroke-opacity", 0.8)
+                    .attr("stroke-dasharray", d => intraEdgeDash[d.type] || "none");
+
+                // 层内边热区(可点击,效果与左边圆一致)
+                intraLinks.append("path")
+                    .attr("class", "intra-link-hitarea")
+                    .attr("d", intraLinkPath)
+                    .attr("fill", "none")
+                    .attr("stroke", "transparent")
+                    .attr("stroke-width", 15)
+                    .style("cursor", "pointer")
+                    .on("mouseover", function(event, d) {{
+                        d3.select(this.parentNode).select(".intra-link")
+                            .attr("stroke-width", 3)
+                            .attr("stroke-opacity", 1);
+                    }})
+                    .on("mouseout", function(event, d) {{
+                        d3.select(this.parentNode).select(".intra-link")
+                            .attr("stroke-width", 2)
+                            .attr("stroke-opacity", 0.8);
+                    }})
+                    .on("click", function(event, d) {{
+                        event.stopPropagation();
+
+                        // 在左边圆中找到对应的边
+                        const srcId = d.source ? (d.source.id || d.source) : null;
+                        const tgtId = d.target ? (d.target.id || d.target) : null;
+                        let foundLink = null;
+                        let foundIndex = -1;
+
+                        links.forEach((link, idx) => {{
+                            const lSrcId = typeof link.source === "object" ? link.source.id : link.source;
+                            const lTgtId = typeof link.target === "object" ? link.target.id : link.target;
+                            if ((lSrcId === srcId && lTgtId === tgtId) || (lSrcId === tgtId && lTgtId === srcId)) {{
+                                if (link.type === d.type) {{
+                                    foundLink = link;
+                                    foundIndex = idx;
+                                }}
+                            }}
+                        }});
+
+                        if (foundLink) {{
+                            // 1. 高亮左边圆的边
+                            highlightEdge(foundLink, foundIndex);
+                            showEdgeInfo(foundLink);
+
+                            // 2. 同步人设树高亮和关系图展示
+                            const edgeDetail = foundLink.边详情 || {{}};
+                            const isMirrorEdge = foundLink.type.startsWith("镜像_") || foundLink.type.startsWith("二阶_");
+
+                            let personaPathNodes = [];
+                            let edgeType;
+
+                            if (isMirrorEdge && edgeDetail.路径节点) {{
+                                personaPathNodes = edgeDetail.路径节点.filter(id => !id.startsWith("帖子_"));
+                                edgeType = edgeDetail.原始边类型 || foundLink.type.replace("镜像_", "").replace("二阶_", "");
+                            }} else {{
+                                const sourceNode = typeof foundLink.source === "object" ? foundLink.source : nodes.find(n => n.id === foundLink.source);
+                                const targetNode = typeof foundLink.target === "object" ? foundLink.target : nodes.find(n => n.id === foundLink.target);
+                                const sId = sourceNode ? sourceNode.id : foundLink.source;
+                                const tId = targetNode ? targetNode.id : foundLink.target;
+                                if (!sId.startsWith("帖子_")) personaPathNodes.push(sId);
+                                if (!tId.startsWith("帖子_")) personaPathNodes.push(tId);
+                                edgeType = foundLink.type;
+                            }}
+
+                            syncTreeAndRelationGraph(personaPathNodes, edgeType);
+                        }} else {{
+                            // 没找到对应边,只显示详情
+                            showEdgeInfo(d);
+                        }}
+                    }});
+
+                // 层内边分数标签(直接显示在弧线上)
+                // 计算二次贝塞尔曲线在 t=0.5 时的实际位置(曲线中点)
+                const getIntraArcMid = d => {{
+                    const dist = Math.sqrt(
+                        Math.pow(d.tgtNode.x - d.srcNode.x, 2) +
+                        Math.pow(d.tgtNode.y - d.srcNode.y, 2)
+                    );
+                    const baseArc = Math.min(dist * 0.2, 30);
+                    const arcStep = 20;
+                    const arcIndex = d._arcIndex || 0;
+                    const arcHeight = baseArc + arcIndex * arcStep;
+                    // 二次贝塞尔曲线 t=0.5 时的点: (1-t)²*P0 + 2*(1-t)*t*P1 + t²*P2
+                    // = 0.25*src + 0.5*ctrl + 0.25*tgt
+                    const midY = (d.srcNode.y + d.tgtNode.y) / 2;
+                    const ctrlY = midY - arcHeight;
+                    return {{
+                        x: (d.srcNode.x + d.tgtNode.x) / 2,
+                        y: 0.25 * d.srcNode.y + 0.5 * ctrlY + 0.25 * d.tgtNode.y
+                    }};
+                }};
+
+                // 分数背景(让文字在边上更清晰)
+                intraLinks.filter(d => d.score !== null && d.score !== undefined)
+                    .append("rect")
+                    .attr("class", "intra-link-score-bg")
+                    .attr("x", d => getIntraArcMid(d).x - 14)
+                    .attr("y", d => getIntraArcMid(d).y - 7)
+                    .attr("width", 28)
+                    .attr("height", 14)
+                    .attr("rx", 3)
+                    .attr("fill", "rgba(0,0,0,0.7)");
+
+                // 分数文字
+                intraLinks.filter(d => d.score !== null && d.score !== undefined)
+                    .append("text")
+                    .attr("class", "intra-link-score")
+                    .attr("x", d => getIntraArcMid(d).x)
+                    .attr("y", d => getIntraArcMid(d).y)
+                    .attr("dy", "0.35em")
+                    .attr("text-anchor", "middle")
+                    .attr("fill", "#fff")
+                    .attr("font-size", "9px")
+                    .attr("font-weight", "bold")
+                    .text(d => d.score.toFixed(2));
+
+                // 存储层内边引用供后续使用
+                window.intraLayerLinksRef = intraLinks;
+                window.intraLayerLinksData = dedupedIntraLinks;
+
                 postTreeActualHeight = targetExpandedY - (targetTagY - halfGap * 3) + 100;
             }} // end if (postTree && postTree.root)
 
@@ -3384,70 +3680,57 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 }});
             }}
 
-            // 点击边时的高亮逻辑
-            function highlightEdge(clickedLink, clickedIndex) {{
-                const highlightNodeIds = new Set();
-                const highlightLinkIndices = new Set([clickedIndex]);
+            // 收集单条边的高亮数据(供复用)
+            function collectEdgeHighlightData(clickedLink, clickedIndex) {{
+                const nodeIds = new Set();
+                const linkIndices = new Set([clickedIndex]);
+                const pathNodes = new Set();
 
                 const sourceId = typeof clickedLink.source === "object" ? clickedLink.source.id : clickedLink.source;
                 const targetId = typeof clickedLink.target === "object" ? clickedLink.target.id : clickedLink.target;
 
-                highlightNodeIds.add(sourceId);
-                highlightNodeIds.add(targetId);
+                nodeIds.add(sourceId);
+                nodeIds.add(targetId);
+                pathNodes.add(sourceId);
+                pathNodes.add(targetId);
 
-                // 如果是镜像边或二阶边,使用预计算的路径节点高亮
+                // 如果是镜像边或二阶边,使用预计算的路径节点
                 if ((clickedLink.type.startsWith("镜像_") || clickedLink.type.startsWith("二阶_")) && clickedLink.边详情) {{
                     const detail = clickedLink.边详情;
-                    const pathNodes = new Set([sourceId, targetId]);
-
-                    // 使用预计算的路径节点
                     (detail.路径节点 || []).forEach(n => pathNodes.add(n));
-
-                    // 高亮路径上的所有节点
-                    pathNodes.forEach(n => highlightNodeIds.add(n));
-
-                    // 高亮连接路径节点的边
-                    links.forEach((link, i) => {{
-                        const lSrc = typeof link.source === "object" ? link.source.id : link.source;
-                        const lTgt = typeof link.target === "object" ? link.target.id : link.target;
-                        if (pathNodes.has(lSrc) && pathNodes.has(lTgt)) {{
-                            highlightLinkIndices.add(i);
-                        }}
-                    }});
                 }}
                 // 如果是人设边(两端都不是帖子节点),找对应的镜像边
                 else if (!sourceId.startsWith("帖子_") && !targetId.startsWith("帖子_")) {{
-                    // 收集路径节点
-                    const pathNodes = new Set([sourceId, targetId]);
-
-                    // 找匹配的镜像边,读取其路径节点
                     links.forEach((link, i) => {{
                         const detail = link.边详情 || {{}};
                         if ((link.type.startsWith("镜像_") || link.type.startsWith("二阶_")) && detail.源人设节点 && detail.目标人设节点) {{
                             const matches = (detail.源人设节点 === sourceId && detail.目标人设节点 === targetId) ||
                                             (detail.源人设节点 === targetId && detail.目标人设节点 === sourceId);
                             if (matches) {{
-                                highlightLinkIndices.add(i);
-                                // 直接使用预存的路径节点
+                                linkIndices.add(i);
                                 (detail.路径节点 || []).forEach(n => pathNodes.add(n));
                             }}
                         }}
                     }});
+                }}
 
-                    // 高亮路径上的所有节点
-                    pathNodes.forEach(n => highlightNodeIds.add(n));
+                // 高亮路径上的所有节点
+                pathNodes.forEach(n => nodeIds.add(n));
 
-                    // 高亮连接路径节点的边
-                    links.forEach((link, i) => {{
-                        const lSrc = typeof link.source === "object" ? link.source.id : link.source;
-                        const lTgt = typeof link.target === "object" ? link.target.id : link.target;
-                        // 两端都在路径上的边
-                        if (pathNodes.has(lSrc) && pathNodes.has(lTgt)) {{
-                            highlightLinkIndices.add(i);
-                        }}
-                    }});
-                }}
+                // 高亮连接路径节点的边
+                links.forEach((link, i) => {{
+                    const lSrc = typeof link.source === "object" ? link.source.id : link.source;
+                    const lTgt = typeof link.target === "object" ? link.target.id : link.target;
+                    if (pathNodes.has(lSrc) && pathNodes.has(lTgt)) {{
+                        linkIndices.add(i);
+                    }}
+                }});
 
+                return {{ nodeIds, linkIndices, pathNodes }};
+            }}
+
+            // 应用高亮数据(统一处理高亮结果)
+            function applyEdgeHighlight(highlightNodeIds, highlightLinkIndices) {{
                 // 高亮相关的节点和边
                 highlightElements(highlightNodeIds, highlightLinkIndices);
 
@@ -3470,6 +3753,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 highlightRightTree(highlightNodeIds, highlightEdgeKeys);
             }}
 
+            // 点击边时的高亮逻辑
+            function highlightEdge(clickedLink, clickedIndex) {{
+                const {{ nodeIds, linkIndices }} = collectEdgeHighlightData(clickedLink, clickedIndex);
+                applyEdgeHighlight(nodeIds, linkIndices);
+            }}
+
             // 点击空白处清除高亮(合并所有空白点击逻辑)
             svg.on("click", (event) => {{
                 // 检查是否点击的是空白区域(svg本身或layer-backgrounds)
@@ -4633,6 +4922,23 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     const key2 = `${{tgtId}}|${{srcId}}`;
                     return (pathEdgeKeys.has(key1) || pathEdgeKeys.has(key2)) ? 1 : 0.2;
                 }});
+
+            // 显示并高亮层内边(只显示左边圆形图中存在的边)
+            d3.selectAll(".intra-link-group").each(function(d) {{
+                const srcId = d.source ? (d.source.id || d.source) : null;
+                const tgtId = d.target ? (d.target.id || d.target) : null;
+                if (!srcId || !tgtId) {{
+                    d3.select(this).style("display", "none");
+                    return;
+                }}
+                // 只显示在左边圆形图中高亮的边(来自 pathEdgeKeys)
+                const key1 = `${{srcId}}|${{tgtId}}`;
+                const key2 = `${{tgtId}}|${{srcId}}`;
+                const isInLeftGraph = pathEdgeKeys.has(key1) || pathEdgeKeys.has(key2);
+                d3.select(this)
+                    .style("display", isInLeftGraph ? "block" : "none")
+                    .style("opacity", isInLeftGraph ? 1 : 0.2);
+            }});
         }}
 
         // 恢复右边树的高亮状态
@@ -4650,6 +4956,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             // 恢复跨层边
             d3.selectAll(".graph-link-group").style("opacity", 1);
+
+            // 隐藏层内边(恢复默认隐藏状态)
+            d3.selectAll(".intra-link-group")
+                .style("display", "none")
+                .style("opacity", 1);
         }}
 
         // 渲染路径上的所有节点和边(点击镜像边/二阶边时调用)