浏览代码

feat: 优化边点击高亮,预计算路径节点

- 统一镜像边和二阶边的边详情结构,使用源人设节点/目标人设节点
- 在数据构建阶段预计算路径节点列表,简化前端高亮逻辑
- 分离highlightEdge和syncTreeAndRelationGraph职责
- 点击人设边可正确高亮对应的镜像边和完整路径

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 5 天之前
父节点
当前提交
4207fcbb3c
共有 2 个文件被更改,包括 143 次插入81 次删除
  1. 31 8
      script/data_processing/build_match_graph.py
  2. 112 73
      script/data_processing/visualize_match_graph.py

+ 31 - 8
script/data_processing/build_match_graph.py

@@ -284,7 +284,9 @@ def create_mirrored_post_edges(
                     "边详情": {
                         "原始边类型": edge_type,
                         "源人设节点": source_persona,
-                        "目标人设节点": target_persona
+                        "目标人设节点": target_persona,
+                        # 完整路径节点(用于前端高亮)
+                        "路径节点": [src_post, source_persona, target_persona, tgt_post]
                     }
                 }
                 post_edges.append(post_edge)
@@ -609,10 +611,10 @@ def process_filtered_result(
                             "边类型": f"二阶_{edge_type}",
                             "边详情": {
                                 "原始边类型": edge_type,
-                                "分类节点1": cat1,
-                                "分类节点2": cat2,
-                                "标签节点1": tag1,
-                                "标签节点2": tag2
+                                "源人设节点": cat1,  # 统一字段:指向产生关系的人设节点(分类)
+                                "目标人设节点": cat2,
+                                # 完整路径节点(用于前端高亮)
+                                "路径节点": [post1, tag1, cat1, cat2, tag2, post2]
                             }
                         })
 
@@ -620,8 +622,8 @@ def process_filtered_result(
     # 1. 找出产生了二阶帖子边的扩展节点(分类)
     useful_expanded_ids = set()
     for edge in post_edges_via_expanded:
-        cat1 = edge.get("边详情", {}).get("分类节点1")
-        cat2 = edge.get("边详情", {}).get("分类节点2")
+        cat1 = edge.get("边详情", {}).get("源人设节点")
+        cat2 = edge.get("边详情", {}).get("目标人设节点")
         if cat1:
             useful_expanded_ids.add(cat1)
         if cat2:
@@ -653,6 +655,26 @@ def process_filtered_result(
             unique_edges.append(edge)
     all_edges = unique_edges
 
+    # 构建人设边到镜像边的反向映射
+    # key: "源人设节点ID|目标人设节点ID" (排序后的)
+    # value: [{镜像边信息}, ...]
+    persona_edge_to_mirror_edges = {}
+    all_mirror_edges = post_edges + post_edges_via_expanded
+    for mirror_edge in all_mirror_edges:
+        detail = mirror_edge.get("边详情", {})
+        src_persona = detail.get("源人设节点")
+        tgt_persona = detail.get("目标人设节点")
+        if src_persona and tgt_persona:
+            # 使用排序后的key,确保 A|B 和 B|A 映射到同一个key
+            edge_key = "|".join(sorted([src_persona, tgt_persona]))
+            if edge_key not in persona_edge_to_mirror_edges:
+                persona_edge_to_mirror_edges[edge_key] = []
+            persona_edge_to_mirror_edges[edge_key].append({
+                "源节点ID": mirror_edge["源节点ID"],
+                "目标节点ID": mirror_edge["目标节点ID"],
+                "边类型": mirror_edge["边类型"]
+            })
+
     # 构建节点边索引
     edges_by_node = {}
     for edge in all_edges:
@@ -701,7 +723,8 @@ def process_filtered_result(
         "帖子镜像边列表(二阶)": post_edges_via_expanded,
         "节点列表": all_nodes,
         "边列表": all_edges,
-        "节点边索引": edges_by_node
+        "节点边索引": edges_by_node,
+        "人设边到镜像边映射": persona_edge_to_mirror_edges
     }
 
     # 保存输出文件

+ 112 - 73
script/data_processing/visualize_match_graph.py

@@ -1373,16 +1373,17 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 "关键点": "#9b59b6"
             }};
 
-            // 获取节点的层级编号(层结构)
+            // 获取节点的层级编号(层结构)
             function getNodeLayer(d) {{
                 if (d.source === "帖子") {{
                     return d.节点类型 === "点" ? 0 : 1;  // 点=0, 标签=1
                 }}
-                return 2;  // 人设(标签+分类)都在层2
+                // 人设节点:根据是否扩展分成两层
+                return d.是否扩展 ? 3 : 2;  // 直接匹配=2, 扩展=3
             }}
 
-            // 统计每层节点数量(层结构)
-            const layerCounts = {{0: 0, 1: 0, 2: 0}};
+            // 统计每层节点数量(层结构)
+            const layerCounts = {{0: 0, 1: 0, 2: 0, 3: 0}};
             nodes.forEach(n => {{
                 const layer = getNodeLayer(n);
                 layerCounts[layer]++;
@@ -1403,10 +1404,17 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return Math.max(minRadius, Math.min(maxRadius, r));
             }}
 
+            // 左列3层统一大小(取最大值)
+            const leftColRadius = Math.max(
+                calcRadius(layerCounts[1]),
+                calcRadius(layerCounts[2]),
+                calcRadius(layerCounts[3])
+            );
             const layerRadius = {{
                 0: calcRadius(layerCounts[0]),  // 帖子点(右列)
-                1: calcRadius(layerCounts[1]),  // 帖子标签(左上)
-                2: calcRadius(layerCounts[2])   // 人设匹配(左下)
+                1: leftColRadius,  // 帖子标签(左上)
+                2: leftColRadius,  // 人设匹配1层(左中)
+                3: leftColRadius   // 人设匹配2层(左下)
             }};
             console.log("每层半径:", layerRadius);
 
@@ -1419,9 +1427,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const egoRadius = 180;
             const egoAreaHeight = egoRadius * 2 + 80;
 
-            // 计算布局:左列(帖子标签+人设匹配),右列(帖子点)
-            const layerPadding = 40;
-            const leftColHeight = layerRadius[1] * 2 + layerPadding + layerRadius[2] * 2 + layerPadding;
+            // 计算布局:左列(帖子标签+人设匹配1层+人设匹配2层),右列(帖子点)
+            const layerPadding = 30;
+            const leftColHeight = layerRadius[1] * 2 + layerPadding + layerRadius[2] * 2 + layerPadding + layerRadius[3] * 2 + layerPadding;
             const circleHeight = leftColHeight;
             const height = Math.max(circleHeight + 80, treeHeight + 80, container.clientHeight);
 
@@ -1431,13 +1439,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             // 左列居中显示,右列在旁边
             const leftColX = circleAreaCenterX;  // 左列居中
-            const rightColX = leftColX + Math.max(layerRadius[1], layerRadius[2]) + layerPadding + layerRadius[0] + 30;  // 右列在右边
+            const rightColX = leftColX + Math.max(layerRadius[1], layerRadius[2], layerRadius[3]) + layerPadding + layerRadius[0] + 30;  // 右列在右边
 
-            // 左列:帖子标签(上)+ 人设匹配(下)
+            // 左列:帖子标签(上)+ 人设匹配1层(中)+ 人设匹配2层(下)
             layerCenterX[1] = leftColX;
             layerCenterY[1] = layerRadius[1] + layerPadding / 2 + 20;
             layerCenterX[2] = leftColX;
             layerCenterY[2] = layerCenterY[1] + layerRadius[1] + layerPadding + layerRadius[2];
+            layerCenterX[3] = leftColX;
+            layerCenterY[3] = layerCenterY[2] + layerRadius[2] + layerPadding + layerRadius[3];
 
             // 右列:帖子点(与帖子标签同一行开始)
             layerCenterX[0] = rightColX;
@@ -1558,7 +1568,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
                 function force(alpha) {{
                     // 按层分组(三层)
-                    const layerNodes = {{0: [], 1: [], 2: []}};
+                    const layerNodes = {{0: [], 1: [], 2: [], 3: []}};
                     nodes.forEach(node => {{
                         const layer = getNodeLayer(node);
                         layerNodes[layer].push(node);
@@ -1676,7 +1686,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const layerConfig = [
                 {{ name: "帖子点", layer: 0, color: "rgba(243, 156, 18, 0.08)", stroke: "rgba(243, 156, 18, 0.3)" }},
                 {{ name: "帖子标签", layer: 1, color: "rgba(52, 152, 219, 0.08)", stroke: "rgba(52, 152, 219, 0.3)" }},
-                {{ name: "人设匹配", layer: 2, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }}
+                {{ name: "人设匹配", layer: 2, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }},
+                {{ name: "人设扩展", layer: 3, color: "rgba(46, 204, 113, 0.08)", stroke: "rgba(46, 204, 113, 0.3)" }}
             ];
 
             // 绘制三层圆形背景
@@ -1980,16 +1991,33 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             linkHitarea.on("click", (event, d, i) => {{
                 event.stopPropagation();
                 const linkIndex = links.indexOf(d);
+
+                // 1. 力导向图高亮(highlightEdge 处理所有路径高亮逻辑)
                 highlightEdge(d, linkIndex);
                 showEdgeInfo(d);
-                // 边联动:如果边涉及人设节点,联动人设树和关系图
-                const sourceNode = typeof d.source === "object" ? d.source : nodes.find(n => n.id === d.source);
-                const targetNode = typeof d.target === "object" ? d.target : nodes.find(n => n.id === d.target);
-                if (sourceNode && sourceNode.source === "人设") {{
-                    handleNodeClick(sourceNode.节点ID, sourceNode.节点名称);
-                }} else if (targetNode && targetNode.source === "人设") {{
-                    handleNodeClick(targetNode.节点ID, targetNode.节点名称);
+
+                // 2. 确定要在人设树和关系图中展示的人设边
+                const edgeDetail = d.边详情 || {{}};
+                const isMirrorEdge = d.type.startsWith("镜像_") || d.type.startsWith("二阶_");
+
+                let personaSrcId, personaTgtId, edgeType;
+
+                if (isMirrorEdge && edgeDetail.源人设节点 && edgeDetail.目标人设节点) {{
+                    // 镜像边:使用对应的人设节点
+                    personaSrcId = edgeDetail.源人设节点;
+                    personaTgtId = edgeDetail.目标人设节点;
+                    edgeType = edgeDetail.原始边类型 || d.type.replace("镜像_", "").replace("二阶_", "");
+                }} else {{
+                    // 普通边:使用边的两端节点
+                    const sourceNode = typeof d.source === "object" ? d.source : nodes.find(n => n.id === d.source);
+                    const targetNode = typeof d.target === "object" ? d.target : nodes.find(n => n.id === d.target);
+                    personaSrcId = sourceNode ? sourceNode.id : d.source;
+                    personaTgtId = targetNode ? targetNode.id : d.target;
+                    edgeType = d.type;
                 }}
+
+                // 3. 同步人设树高亮和关系图展示
+                syncTreeAndRelationGraph(personaSrcId, personaTgtId, edgeType);
             }})
             .on("mouseover", function(event, d) {{
                 d3.select(this.parentNode).select(".link")
@@ -2306,6 +2334,38 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         }}
                     }});
                 }}
+                // 如果是人设边(两端都不是帖子节点),找对应的镜像边
+                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);
+                                // 直接使用预存的路径节点
+                                (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);
+                        }}
+                    }});
+                }}
 
                 // 高亮相关的节点和边
                 highlightElements(highlightNodeIds, highlightLinkIndices);
@@ -2756,15 +2816,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             }}
         }}
 
-        // 共享的边点击处理函数(人设树和关系图复用
-        function handleEdgeClick(srcNodeId, tgtNodeId, edgeType) {{
+        // 同步人设树高亮和关系图展示(职责单一:不处理力导向图
+        function syncTreeAndRelationGraph(srcNodeId, tgtNodeId, edgeType) {{
             // 动态获取树相关元素
             const treeGroup = d3.select(".persona-tree");
             if (treeGroup.empty()) return;
 
             const treeEdges = treeGroup.selectAll(".tree-edge");
 
-            // 先重置所有高亮状态
+            // 重置人设树高亮状态
             treeGroup.selectAll(".tree-node")
                 .classed("tree-dimmed", false)
                 .classed("tree-highlighted", false);
@@ -2778,10 +2838,6 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("fill", d => (d.data.isRoot || d.data.isDimension) ? getTreeNodeColor(d) : "#bbb")
                 .attr("opacity", 1);
             treeEdges.attr("stroke-opacity", 0.3).attr("stroke-width", 1);
-            if (g) {{
-                g.selectAll(".node").classed("dimmed", false).classed("highlighted", false);
-                g.selectAll(".link-group").classed("dimmed", false).classed("highlighted", false);
-            }}
 
             // 构建节点ID到D3节点的映射
             const treeNodeById = {{}};
@@ -2799,29 +2855,35 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return isThisEdge ? 1 : 0.1;
             }});
 
-            // 高亮相关节点
-            const connectedNodes = new Set();
-            if (sourceTreeNode) connectedNodes.add(sourceTreeNode);
-            if (targetTreeNode) connectedNodes.add(targetTreeNode);
+            // 高亮相关节点(使用节点ID集合判断)
+            const connectedNodeIds = new Set([srcNodeId, tgtNodeId]);
 
-            treeGroup.selectAll(".tree-node .tree-shape")
-                .attr("fill", n => connectedNodes.has(n) ? getTreeNodeColor(n) : "#555")
-                .attr("opacity", 1);
-
-            treeGroup.selectAll(".tree-node text")
-                .attr("fill", n => connectedNodes.has(n) ?
-                    ((n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb") : "#555")
-                .attr("opacity", 1);
+            treeGroup.selectAll(".tree-node").each(function(n) {{
+                const nodeId = n.data.节点ID;
+                const isConnected = connectedNodeIds.has(nodeId);
+                d3.select(this).select(".tree-shape")
+                    .attr("fill", isConnected ? getTreeNodeColor(n) : "#555")
+                    .attr("opacity", 1);
+                d3.select(this).select("text")
+                    .attr("fill", isConnected ?
+                        ((n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb") : "#555")
+                    .attr("opacity", 1);
+            }});
 
             // 显示边详情
             const panel = document.getElementById("detailPanel");
             panel.classList.add("active");
             document.getElementById("detailTitle").textContent = "🔗 边详情";
 
-            const srcNode = personaTreeData.nodes.find(n => n.节点ID === srcNodeId);
-            const tgtNode = personaTreeData.nodes.find(n => n.节点ID === tgtNodeId);
-            const srcName = sourceTreeNode ? (sourceTreeNode.data.节点名称 || sourceTreeNode.data.name) : (srcNode?.节点名称 || srcNodeId);
-            const tgtName = targetTreeNode ? (targetTreeNode.data.节点名称 || targetTreeNode.data.name) : (tgtNode?.节点名称 || tgtNodeId);
+            // 从 personaTreeData 中获取节点数据(即使不在可视化树中)
+            const srcNodeData = personaTreeData.nodes.find(n => n.节点ID === srcNodeId);
+            const tgtNodeData = personaTreeData.nodes.find(n => n.节点ID === tgtNodeId);
+
+            // 构造显示用的节点名称
+            const srcName = sourceTreeNode ? (sourceTreeNode.data.节点名称 || sourceTreeNode.data.name) :
+                            (srcNodeData?.节点名称 || srcNodeId);
+            const tgtName = targetTreeNode ? (targetTreeNode.data.节点名称 || targetTreeNode.data.name) :
+                            (tgtNodeData?.节点名称 || tgtNodeId);
 
             let html = `
                 <p><span class="label">边类型:</span> <strong>${{edgeType}}</strong></p>
@@ -2830,41 +2892,17 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             `;
             document.getElementById("detailContent").innerHTML = html;
 
-            // 高亮右侧人设匹配区域
-            if (g) {{
-                const relatedNodeIds = new Set([srcNodeId, tgtNodeId]);
-                const hasRelatedNodes = g.selectAll(".node").filter(n => relatedNodeIds.has(n.id)).size() > 0;
-
-                if (hasRelatedNodes) {{
-                    // 高亮相关节点
-                    g.selectAll(".node")
-                        .classed("dimmed", n => !relatedNodeIds.has(n.id))
-                        .classed("highlighted", n => relatedNodeIds.has(n.id));
-                    // 高亮相关边
-                    g.selectAll(".link-group").each(function(d) {{
-                        const sid = typeof d.source === "object" ? d.source.id : d.source;
-                        const tid = typeof d.target === "object" ? d.target.id : d.target;
-                        const isRelated = (sid === srcNodeId && tid === tgtNodeId) ||
-                                          (sid === tgtNodeId && tid === srcNodeId) ||
-                                          relatedNodeIds.has(sid) || relatedNodeIds.has(tid);
-                        d3.select(this)
-                            .classed("dimmed", !isRelated)
-                            .classed("highlighted", isRelated);
-                    }});
-                }} else {{
-                    // 全部变灰
-                    g.selectAll(".node").classed("dimmed", true).classed("highlighted", false);
-                    g.selectAll(".link-group").classed("dimmed", true).classed("highlighted", false);
-                }}
-            }}
-
             // 在关系图中展示这条边和两个节点
+            // 构造节点对象(优先使用树节点,否则使用 personaTreeData 中的数据)
+            const srcForEgo = sourceTreeNode || (srcNodeData ? {{ data: srcNodeData }} : null);
+            const tgtForEgo = targetTreeNode || (tgtNodeData ? {{ data: tgtNodeData }} : null);
+
             const edgeData = {{
                 边类型: edgeType,
                 源节点ID: srcNodeId,
                 目标节点ID: tgtNodeId
             }};
-            renderEgoGraphEdge(edgeData, sourceTreeNode, targetTreeNode);
+            renderEgoGraphEdge(edgeData, srcForEgo, tgtForEgo);
         }}
 
         // 关系子图(Ego Graph)- 在画布第四层显示
@@ -3672,7 +3710,8 @@ def main():
             "postTitle": match_graph_data["说明"].get("帖子标题", ""),
             "stats": match_graph_data["说明"]["统计"],
             "nodes": match_graph_data["节点列表"],
-            "edges": match_graph_data["边列表"]
+            "edges": match_graph_data["边列表"],
+            "personaEdgeToMirrorEdges": match_graph_data.get("人设边到镜像边映射", {})
         }
         all_graph_data.append(graph_data)