Prechádzať zdrojové kódy

Merge remote-tracking branch 'origin/how_1128_v2' into how_1202_v1

yangxiaohui 1 deň pred
rodič
commit
9609f07c35

+ 174 - 38
script/data_processing/build_persona_graph.py

@@ -207,10 +207,7 @@ def extract_category_nodes_from_pattern(
             if isinstance(value, dict):
                 current_path = parent_path + [key]
 
-                # 获取帖子列表
-                post_ids = value.get("帖子列表", [])
-
-                # 构建节点来源
+                # 构建节点来源(只收集当前节点的特征)
                 node_sources = []
                 if "特征列表" in value:
                     for feature in value["特征列表"]:
@@ -220,14 +217,10 @@ def extract_category_nodes_from_pattern(
                             "postId": feature.get("帖子id", "")
                         }
                         node_sources.append(source)
-                else:
-                    node_sources = collect_sources_recursively(value)
 
-                # 计算帖子数
-                if post_ids:
-                    post_count = len(post_ids)
-                else:
-                    post_count = len(set(s.get("postId", "") for s in node_sources if s.get("postId")))
+                # 收集帖子ID列表(递归收集当前节点及所有子节点的帖子ID,去重)
+                all_sources = collect_sources_recursively(value)
+                unique_post_ids = list(set(s.get("postId", "") for s in all_sources if s.get("postId")))
 
                 # 构建节点
                 node_id = build_node_id("人设", dimension_name, "分类", key)
@@ -238,7 +231,8 @@ def extract_category_nodes_from_pattern(
                     name=key,
                     detail={
                         "parentPath": parent_path.copy(),
-                        "postCount": post_count,
+                        "postIds": unique_post_ids,
+                        "postCount": len(unique_post_ids),
                         "sources": node_sources
                     }
                 )
@@ -318,6 +312,7 @@ def extract_tag_nodes_from_pattern(
             name=tag_info["name"],
             detail={
                 "parentPath": tag_info["parentPath"],
+                "postIds": list(tag_info["postIds"]),
                 "postCount": len(tag_info["postIds"]),
                 "sources": tag_info["sources"]
             }
@@ -365,6 +360,10 @@ def extract_belong_contain_edges(
         parent_id = category_name_to_id.get(parent_name)
 
         if parent_id:
+            # 获取 source 和 target 的 postIds
+            child_post_ids = node_data["detail"].get("postIds", [])
+            parent_post_ids = nodes.get(parent_id, {}).get("detail", {}).get("postIds", [])
+
             # 属于边:子 → 父
             edge_id = build_edge_id(node_id, "属于", parent_id)
             edges[edge_id] = create_edge(
@@ -372,7 +371,10 @@ def extract_belong_contain_edges(
                 target=parent_id,
                 edge_type="属于",
                 score=1.0,
-                detail={}
+                detail={
+                    "sourcePostIds": child_post_ids,
+                    "targetPostIds": parent_post_ids
+                }
             )
 
             # 包含边:父 → 子
@@ -382,7 +384,10 @@ def extract_belong_contain_edges(
                 target=node_id,
                 edge_type="包含",
                 score=1.0,
-                detail={}
+                detail={
+                    "sourcePostIds": parent_post_ids,
+                    "targetPostIds": child_post_ids
+                }
             )
 
     return edges
@@ -390,10 +395,14 @@ def extract_belong_contain_edges(
 
 # ==================== 从关联分析提取分类共现边(跨点)====================
 
-def extract_category_cooccur_edges(associations_data: Dict) -> Dict[str, Dict]:
+def extract_category_cooccur_edges(associations_data: Dict, nodes: Dict[str, Dict]) -> Dict[str, Dict]:
     """
     从 dimension_associations_analysis.json 中提取分类共现边(跨点)
 
+    Args:
+        associations_data: 关联分析数据
+        nodes: 已构建的节点数据(用于获取节点的 postIds)
+
     Returns:
         { edgeId: edgeData }
     """
@@ -449,6 +458,10 @@ def extract_category_cooccur_edges(associations_data: Dict) -> Dict[str, Dict]:
                         # 使用 Jaccard 作为 score
                         jaccard = assoc.get("Jaccard相似度", 0)
 
+                        # 获取 source 和 target 的 postIds
+                        source_post_ids = nodes.get(source_node_id, {}).get("detail", {}).get("postIds", [])
+                        target_post_ids = nodes.get(target_node_id, {}).get("detail", {}).get("postIds", [])
+
                         edge_id = build_edge_id(source_node_id, "分类共现", target_node_id)
                         edges[edge_id] = create_edge(
                             source=source_node_id,
@@ -456,10 +469,12 @@ def extract_category_cooccur_edges(associations_data: Dict) -> Dict[str, Dict]:
                             edge_type="分类共现",
                             score=jaccard,
                             detail={
+                                "postIds": assoc.get("共同帖子ID", []),
+                                "postCount": assoc.get("共同帖子数", 0),
                                 "jaccard": jaccard,
                                 "overlapCoef": assoc.get("重叠系数", 0),
-                                "cooccurCount": assoc.get("共同帖子数", 0),
-                                "cooccurPosts": assoc.get("共同帖子ID", [])
+                                "sourcePostIds": source_post_ids,
+                                "targetPostIds": target_post_ids
                             }
                         )
 
@@ -468,10 +483,14 @@ def extract_category_cooccur_edges(associations_data: Dict) -> Dict[str, Dict]:
 
 # ==================== 从关联分析提取分类共现边(点内)====================
 
-def extract_intra_category_cooccur_edges(intra_data: Dict) -> Dict[str, Dict]:
+def extract_intra_category_cooccur_edges(intra_data: Dict, nodes: Dict[str, Dict]) -> Dict[str, Dict]:
     """
     从 intra_dimension_associations_analysis.json 中提取点内分类共现边
 
+    Args:
+        intra_data: 点内关联分析数据
+        nodes: 已构建的节点数据(用于获取节点的 postIds)
+
     Returns:
         { edgeId: edgeData }
     """
@@ -514,14 +533,30 @@ def extract_intra_category_cooccur_edges(intra_data: Dict) -> Dict[str, Dict]:
                         edges[edge_id]["detail"]["pointCount"] += point_count
                         edges[edge_id]["detail"]["pointNames"].extend(point_names)
                     else:
+                        # 获取 source 和 target 的 postIds
+                        cat1_post_ids = nodes.get(cat1_id, {}).get("detail", {}).get("postIds", [])
+                        cat2_post_ids = nodes.get(cat2_id, {}).get("detail", {}).get("postIds", [])
+
+                        # 计算 Jaccard(基于帖子)
+                        cat1_set = set(cat1_post_ids)
+                        cat2_set = set(cat2_post_ids)
+                        intersection = cat1_set & cat2_set
+                        union = cat1_set | cat2_set
+                        jaccard = round(len(intersection) / len(union), 4) if union else 0
+
                         edges[edge_id] = create_edge(
                             source=cat1_id,
                             target=cat2_id,
                             edge_type="分类共现",
-                            score=point_count,  # 先用点数作为 score,后续可归一化
+                            score=jaccard,
                             detail={
+                                "postIds": list(intersection),
+                                "postCount": len(intersection),
+                                "jaccard": jaccard,
                                 "pointCount": point_count,
-                                "pointNames": point_names.copy()
+                                "pointNames": point_names.copy(),
+                                "sourcePostIds": cat1_post_ids,
+                                "targetPostIds": cat2_post_ids
                             }
                         )
 
@@ -530,15 +565,19 @@ def extract_intra_category_cooccur_edges(intra_data: Dict) -> Dict[str, Dict]:
 
 # ==================== 从历史帖子提取标签共现边 ====================
 
-def extract_tag_cooccur_edges(historical_posts_dir: Path) -> Dict[str, Dict]:
+def extract_tag_cooccur_edges(historical_posts_dir: Path, nodes: Dict[str, Dict]) -> Dict[str, Dict]:
     """
     从历史帖子解构结果中提取标签共现边
 
+    Args:
+        historical_posts_dir: 历史帖子目录
+        nodes: 已构建的节点数据(用于获取标签的 postIds 计算 Jaccard)
+
     Returns:
         { edgeId: edgeData }
     """
     edges = {}
-    cooccur_map = {}  # (tag1_id, tag2_id, dimension) -> { cooccurPosts: set() }
+    cooccur_map = {}  # (tag1_id, tag2_id) -> { postIds: set() }
 
     if not historical_posts_dir.exists():
         print(f"  警告: 历史帖子目录不存在: {historical_posts_dir}")
@@ -632,27 +671,37 @@ def extract_tag_cooccur_edges(historical_posts_dir: Path) -> Dict[str, Dict]:
                         key = (tag1_id, tag2_id)
 
                         if key not in cooccur_map:
-                            cooccur_map[key] = {"cooccurPosts": set()}
+                            cooccur_map[key] = {"postIds": set()}
 
-                        cooccur_map[key]["cooccurPosts"].add(post_id)
+                        cooccur_map[key]["postIds"].add(post_id)
 
         except Exception as e:
             print(f"  警告: 处理文件 {file_path.name} 时出错: {e}")
 
     # 转换为边
     for (tag1_id, tag2_id), info in cooccur_map.items():
-        cooccur_posts = list(info["cooccurPosts"])
-        cooccur_count = len(cooccur_posts)
+        cooccur_post_ids = list(info["postIds"])
+        cooccur_count = len(cooccur_post_ids)
+
+        # 获取两个标签的帖子集合,计算 Jaccard
+        tag1_post_ids = nodes.get(tag1_id, {}).get("detail", {}).get("postIds", [])
+        tag2_post_ids = nodes.get(tag2_id, {}).get("detail", {}).get("postIds", [])
+
+        union_count = len(set(tag1_post_ids) | set(tag2_post_ids))
+        jaccard = round(cooccur_count / union_count, 4) if union_count > 0 else 0
 
         edge_id = build_edge_id(tag1_id, "标签共现", tag2_id)
         edges[edge_id] = create_edge(
             source=tag1_id,
             target=tag2_id,
             edge_type="标签共现",
-            score=cooccur_count,  # 先用共现次数,后续可归一化
+            score=jaccard,
             detail={
-                "cooccurCount": cooccur_count,
-                "cooccurPosts": cooccur_posts
+                "postIds": cooccur_post_ids,
+                "postCount": cooccur_count,
+                "jaccard": jaccard,
+                "sourcePostIds": tag1_post_ids,
+                "targetPostIds": tag2_post_ids
             }
         )
 
@@ -937,45 +986,66 @@ def main():
 
     # 分类共现边(跨点)
     print("\n提取分类共现边(跨点):")
-    category_cooccur_edges = extract_category_cooccur_edges(associations_data)
+    category_cooccur_edges = extract_category_cooccur_edges(associations_data, all_nodes)
     all_edges.update(category_cooccur_edges)
     print(f"  分类共现边: {len(category_cooccur_edges)}")
 
     # 分类共现边(点内)
     print("\n提取分类共现边(点内):")
-    intra_category_edges = extract_intra_category_cooccur_edges(intra_associations_data)
+    intra_category_edges = extract_intra_category_cooccur_edges(intra_associations_data, all_nodes)
     all_edges.update(intra_category_edges)
     print(f"  分类共现边: {len(intra_category_edges)}")
 
     # 标签共现边
     print("\n提取标签共现边:")
-    tag_cooccur_edges = extract_tag_cooccur_edges(historical_posts_dir)
+    tag_cooccur_edges = extract_tag_cooccur_edges(historical_posts_dir, all_nodes)
     all_edges.update(tag_cooccur_edges)
     print(f"  标签共现边: {len(tag_cooccur_edges)}")
 
     # ===== 添加根节点和维度节点 =====
     print("\n添加根节点和维度节点:")
 
+    # 收集所有帖子ID(用于根节点)
+    all_post_ids_for_root = set()
+    for node in all_nodes.values():
+        post_ids = node["detail"].get("postIds", [])
+        all_post_ids_for_root.update(post_ids)
+
     # 根节点
     root_id = "人设:人设:人设:人设"
+    root_post_ids = list(all_post_ids_for_root)
     all_nodes[root_id] = create_node(
         domain="人设",
         dimension="人设",
         node_type="人设",
         name="人设",
-        detail={}
+        detail={
+            "postIds": root_post_ids,
+            "postCount": len(root_post_ids)
+        }
     )
 
     # 维度节点 + 边
     dimensions = ["灵感点", "目的点", "关键点"]
     for dim in dimensions:
+        # 收集该维度下所有节点的帖子ID
+        dim_post_ids = set()
+        for node in all_nodes.values():
+            if node["dimension"] == dim:
+                post_ids = node["detail"].get("postIds", [])
+                dim_post_ids.update(post_ids)
+        dim_post_ids_list = list(dim_post_ids)
+
         dim_id = f"人设:{dim}:{dim}:{dim}"
         all_nodes[dim_id] = create_node(
             domain="人设",
             dimension=dim,
             node_type=dim,
             name=dim,
-            detail={}
+            detail={
+                "postIds": dim_post_ids_list,
+                "postCount": len(dim_post_ids_list)
+            }
         )
 
         # 维度 -> 根 的属于边
@@ -985,7 +1055,10 @@ def main():
             target=root_id,
             edge_type="属于",
             score=1.0,
-            detail={}
+            detail={
+                "sourcePostIds": dim_post_ids_list,
+                "targetPostIds": root_post_ids
+            }
         )
 
         # 根 -> 维度 的包含边
@@ -995,7 +1068,10 @@ def main():
             target=dim_id,
             edge_type="包含",
             score=1.0,
-            detail={}
+            detail={
+                "sourcePostIds": root_post_ids,
+                "targetPostIds": dim_post_ids_list
+            }
         )
 
         # 找该维度下的顶级分类(没有父节点的分类),添加边
@@ -1006,6 +1082,8 @@ def main():
         ]
 
         for cat_id, cat_data in dim_categories:
+            cat_post_ids = cat_data["detail"].get("postIds", [])
+
             # 顶级分类 -> 维度 的属于边
             edge_id = build_edge_id(cat_id, "属于", dim_id)
             all_edges[edge_id] = create_edge(
@@ -1013,7 +1091,10 @@ def main():
                 target=dim_id,
                 edge_type="属于",
                 score=1.0,
-                detail={}
+                detail={
+                    "sourcePostIds": cat_post_ids,
+                    "targetPostIds": dim_post_ids_list
+                }
             )
 
             # 维度 -> 顶级分类 的包含边
@@ -1023,7 +1104,10 @@ def main():
                 target=cat_id,
                 edge_type="包含",
                 score=1.0,
-                detail={}
+                detail={
+                    "sourcePostIds": dim_post_ids_list,
+                    "targetPostIds": cat_post_ids
+                }
             )
 
     print(f"  添加节点: 1 根节点 + 3 维度节点 = 4")
@@ -1039,6 +1123,58 @@ def main():
     for t, count in sorted(edge_type_counts.items(), key=lambda x: -x[1]):
         print(f"  {t}: {count}")
 
+    # ===== 计算节点概率 =====
+    print("\n" + "=" * 60)
+    print("计算节点概率...")
+
+    # 1. 计算总帖子数(所有帖子ID的并集)
+    all_post_ids = set()
+    for node in all_nodes.values():
+        post_ids = node["detail"].get("postIds", [])
+        all_post_ids.update(post_ids)
+    total_post_count = len(all_post_ids)
+    print(f"  总帖子数: {total_post_count}")
+
+    # 2. 为每个节点计算概率
+    for node_id, node in all_nodes.items():
+        post_count = node["detail"].get("postCount", 0)
+
+        # 全局概率
+        if total_post_count > 0:
+            node["detail"]["probGlobal"] = round(post_count / total_post_count, 4)
+        else:
+            node["detail"]["probGlobal"] = 0
+
+        # 相对父节点的概率
+        # 通过"属于"边找父节点
+        parent_edge_id = None
+        for edge_id, edge in all_edges.items():
+            if edge["source"] == node_id and edge["type"] == "属于":
+                parent_node_id = edge["target"]
+                parent_node = all_nodes.get(parent_node_id)
+                if parent_node:
+                    parent_post_count = parent_node["detail"].get("postCount", 0)
+                    if parent_post_count > 0:
+                        node["detail"]["probToParent"] = round(post_count / parent_post_count, 4)
+                    else:
+                        node["detail"]["probToParent"] = 0
+                break
+        else:
+            # 没有父节点(根节点)
+            node["detail"]["probToParent"] = 1.0
+
+    print(f"  已为 {len(all_nodes)} 个节点计算概率")
+
+    # 3. 更新"包含"边的分数(使用子节点的 probToParent)
+    contain_edge_updated = 0
+    for edge_id, edge in all_edges.items():
+        if edge["type"] == "包含":
+            target_node = all_nodes.get(edge["target"])
+            if target_node:
+                edge["score"] = target_node["detail"].get("probToParent", 1.0)
+                contain_edge_updated += 1
+    print(f"  已更新 {contain_edge_updated} 条包含边的分数")
+
     # ===== 构建索引 =====
     print("\n" + "=" * 60)
     print("构建索引...")

+ 21 - 10
script/data_processing/run_graph_pipeline.sh

@@ -10,10 +10,11 @@
 # 本脚本执行:
 #   5. filter_how_results.py      - 过滤how解构结果
 #   6. extract_nodes_and_edges.py - 提取节点和边
-#   7. build_persona_tree.py      - 构建人设树
+#   7. build_persona_graph.py     - 构建人设图谱
 #   8. build_match_graph.py       - 构建匹配图谱
-#   9. build_post_tree.py         - 构建帖子树
-#  10. visualize_match_graph.py   - 生成可视化HTML
+#   9. build_post_graph.py        - 构建帖子图谱
+#  10. visualize_match_graph.py   - 生成匹配图谱可视化HTML
+#  11. visualization/build.py     - 生成人设图谱可视化HTML
 #
 # 使用方式:
 #   ./run_graph_pipeline.sh              # 使用默认账号
@@ -46,7 +47,7 @@ run_step() {
     local step_name=$2
     local script_name=$3
 
-    print_step "$step_num/6" "$step_name"
+    print_step "$step_num/7" "$step_name"
 
     if python "script/data_processing/$script_name"; then
         print_success "$step_name 完成"
@@ -79,17 +80,27 @@ process_account() {
     # 步骤6: 提取节点和边
     run_step 2 "提取节点和边" "extract_nodes_and_edges.py" || return 1
 
-    # 步骤7: 构建人设
-    run_step 3 "构建人设树" "build_persona_tree.py" || return 1
+    # 步骤7: 构建人设图谱
+    run_step 3 "构建人设图谱" "build_persona_graph.py" || return 1
 
     # 步骤8: 构建匹配图谱
     run_step 4 "构建匹配图谱" "build_match_graph.py" || return 1
 
-    # 步骤9: 构建帖子
-    run_step 5 "构建帖子树" "build_post_tree.py" || return 1
+    # 步骤9: 构建帖子图谱
+    run_step 5 "构建帖子图谱" "build_post_graph.py" || return 1
 
-    # 步骤10: 生成可视化HTML
-    run_step 6 "生成可视化HTML" "visualize_match_graph.py" || return 1
+    # 步骤10: 生成匹配图谱可视化HTML
+    run_step 6 "生成匹配图谱可视化" "visualize_match_graph.py" || return 1
+
+    # 步骤11: 生成人设图谱可视化HTML
+    print_step "7/7" "生成人设图谱可视化"
+    if python "script/visualization/build.py"; then
+        print_success "生成人设图谱可视化 完成"
+        echo ""
+    else
+        print_error "生成人设图谱可视化 失败"
+        return 1
+    fi
 
     echo "=========================================="
     print_success "图谱构建与可视化流程完成!"

+ 118 - 49
script/visualization/src/components/GraphView.vue

@@ -332,13 +332,30 @@ function renderGraph() {
       }
     })
     .on('mouseleave', () => {
-      // 如果已锁定,不清除
-      if (store.lockedHoverNodeId) return
+      // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
       store.clearHover()
-      hideLockButton()
+
+      if (store.lockedHoverNodeId) {
+        // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
+        // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
+        applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, null)
+        // 在锁定节点上显示解锁按钮
+        graphNodeSelection.each(function(d) {
+          if (d.id === store.lockedHoverNodeId) {
+            showLockButton(this, true)
+          }
+        })
+      } else {
+        hideLockButton()
+      }
     })
     .on('click', (e, d) => {
       e.stopPropagation()
+      // 锁定状态下点击节点无效果,但提醒用户
+      if (store.lockedHoverNodeId) {
+        shakeLockButton()
+        return
+      }
       store.selectNode(d.id)
     })
 
@@ -383,9 +400,6 @@ function renderGraph() {
   nextTick(updateHighlight)
 }
 
-// 锁定按钮相关
-let showButtonTimer = null  // 延迟显示按钮的定时器
-
 // 在节点文字后面添加锁定按钮(作为独立的 tspan)
 function showLockButton(nodeEl, immediate = false) {
   if (!nodeEl) return
@@ -394,57 +408,48 @@ function showLockButton(nodeEl, immediate = false) {
   const textEl = node.select('text')
   if (textEl.empty()) return
 
+  // 获取当前节点 ID
+  const nodeData = node.datum()
+  const currentNodeId = nodeData?.data?.id || nodeData?.id
+
+  // 判断当前节点是否是已锁定的节点
+  const isThisNodeLocked = store.lockedHoverNodeId && store.lockedHoverNodeId === currentNodeId
+
   // 如果已有按钮,只更新状态
   let btn = textEl.select('.lock-btn')
   if (!btn.empty()) {
-    const isLocked = !!store.lockedHoverNodeId
-    btn.text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
-       .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
-    if (!isLocked) startBreathingAnimation(btn)
+    btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
+       .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
+    if (!isThisNodeLocked) startBreathingAnimation(btn)
     return
   }
 
   // 创建按钮的函数
   const createBtn = () => {
-    // 先清除其他节点的按钮
-    d3.selectAll('.lock-btn').remove()
-
-    const isLocked = !!store.lockedHoverNodeId
+    // 先清除当前 SVG 内其他节点的按钮(不影响另一边)
+    if (svgRef.value) {
+      d3.select(svgRef.value).selectAll('.lock-btn').remove()
+    }
 
     // 添加按钮 tspan(紧跟在文字后面)
     const btn = textEl.append('tspan')
       .attr('class', 'lock-btn')
-      .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
+      .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
       .attr('font-weight', 'bold')
       .style('cursor', 'pointer')
-      .text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
+      .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
       .on('click', (e) => {
         e.stopPropagation()
         handleLockClick()
       })
 
     // 呼吸灯动画(未锁定时)
-    if (!isLocked) {
+    if (!isThisNodeLocked) {
       startBreathingAnimation(btn)
     }
   }
 
-  // 如果是立即显示,不需要延迟
-  if (immediate) {
-    createBtn()
-    return
-  }
-
-  // 取消之前的显示定时器
-  if (showButtonTimer) {
-    clearTimeout(showButtonTimer)
-  }
-
-  // 延迟 400ms 后显示
-  showButtonTimer = setTimeout(() => {
-    createBtn()
-    showButtonTimer = null
-  }, 400)
+  createBtn()
 }
 
 // 呼吸灯动画(只有按钮部分,蓝色呼吸)
@@ -466,34 +471,62 @@ function startBreathingAnimation(btn) {
 
 // 隐藏锁定按钮
 function hideLockButton() {
-  if (showButtonTimer) {
-    clearTimeout(showButtonTimer)
-    showButtonTimer = null
+  if (svgRef.value) {
+    d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
   }
-  d3.selectAll('.lock-btn').interrupt().remove()
+}
+
+// 抖动锁定按钮(提醒用户需要先解锁)
+function shakeLockButton() {
+  d3.selectAll('.lock-btn')
+    .interrupt()
+    .attr('fill', '#fc8181')  // 红色警告
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 0)
+    .transition().duration(200).attr('fill', '#f6ad55')  // 恢复橙色
 }
 
 // 处理锁定按钮点击
 function handleLockClick() {
   const startNodeId = store.selectedNodeId
-  if (store.lockedHoverNodeId) {
+  const currentHoverNodeId = store.hoverNodeId
+
+  // 判断是解锁还是新锁定
+  if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
+    // 点击的是当前锁定节点的按钮 → 解锁(弹出栈)
     store.clearLockedHover()
-    hideLockButton()
-  } else if (startNodeId) {
-    store.lockCurrentHover(startNodeId)
-    const btn = d3.select('.lock-btn')
-    if (!btn.empty()) {
-      btn.interrupt()
+    // 如果还有上一层锁定,更新按钮状态
+    if (store.lockedHoverNodeId) {
+      d3.selectAll('.lock-btn')
+        .interrupt()
         .text(' 🔓解锁')
         .attr('fill', '#f6ad55')
+    } else {
+      // 完全解锁,清除按钮
+      d3.selectAll('.lock-btn').interrupt().remove()
     }
+  } else if (currentHoverNodeId) {
+    // 点击的是新 hover 节点的按钮 → 锁定新路径(压入栈)
+    store.lockCurrentHover(startNodeId)
+    // 更新按钮状态
+    d3.selectAll('.lock-btn')
+      .interrupt()
+      .text(' 🔓解锁')
+      .attr('fill', '#f6ad55')
   }
 }
 
-// 点击空白取消
+// 点击空白取消(锁定状态下无效果)
 function handleSvgClick(event) {
+  // 锁定状态下,点击空白无效果
+  if (store.lockedHoverNodeId) return
+
   if (event.target.tagName === 'svg') {
     store.clearSelection()
+    hideLockButton()
   }
 }
 
@@ -503,6 +536,24 @@ function updateHighlight() {
   applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
 }
 
+// 恢复锁定的 hover 状态(重新渲染后调用)
+function restoreLockedHover() {
+  if (!store.lockedHoverNodeId || !graphNodeSelection) return
+
+  // 恢复高亮效果(传入锁定路径)
+  if (store.hoverPathNodes.size > 0) {
+    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath)
+  }
+
+  // 恢复锁定按钮:找到锁定节点的 DOM 元素
+  graphNodeSelection.each(function(d) {
+    if (d.id === store.lockedHoverNodeId) {
+      showLockButton(this, true)
+    }
+  })
+}
+
 // 监听高亮变化(walkedEdges 或 postWalkedEdges 变化时重新渲染)
 watch([() => store.walkedEdges.length, () => store.postWalkedEdges.length], () => {
   nextTick(renderGraph)
@@ -518,20 +569,34 @@ watch(() => store.selectedEdgeId, () => {
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
 // 监听 hover 状态变化(用于左右联动)
-watch(() => store.hoverPathNodes.size, () => {
+watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   if (!graphNodeSelection || !graphLinkSelection) return
 
   if (store.hoverPathNodes.size > 0) {
-    // 应用 hover 高亮
-    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes)
+    // 应用 hover 高亮(支持嵌套:传入锁定路径)
+    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+    applyHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection, store.hoverPathNodes, lockedPath)
 
-    // 如果是从 PostTreeView 触发的,缩放到显示完整路径
+    // 如果是从 PostTreeView 触发的,缩放到显示完整路径,并显示锁定按钮
     if (store.hoverSource === 'post-tree') {
       zoomToPathNodes(store.hoverPathNodes)
     }
+
+    // 在对应节点上显示锁定按钮(无论来源)
+    if (store.hoverNodeId) {
+      graphNodeSelection.each(function(d) {
+        if (d.id === store.hoverNodeId) {
+          showLockButton(this)
+        }
+      })
+    }
   } else {
     // 清除 hover,恢复原有高亮
     clearHoverHighlight(graphNodeSelection, graphLinkSelection, graphLinkLabelSelection)
+    // 如果没有锁定,隐藏按钮
+    if (!store.lockedHoverNodeId) {
+      hideLockButton()
+    }
   }
 })
 
@@ -595,6 +660,10 @@ function handleTransitionEnd(e) {
     if ((store.selectedNodeId || store.selectedEdgeId) && svgRef.value) {
       renderGraph()
       svgRef.value.style.opacity = '1'
+      nextTick(() => {
+        updateHighlight()
+        restoreLockedHover()  // 恢复锁定的 hover 状态
+      })
     }
   }
 }

+ 133 - 50
script/visualization/src/components/PostTreeView.vue

@@ -360,6 +360,11 @@ let currentRoot = null
 // 处理节点点击
 function handleNodeClick(event, d) {
   event.stopPropagation()
+  // 锁定状态下点击节点无效果,但提醒用户
+  if (store.lockedHoverNodeId) {
+    shakeLockButton()
+    return
+  }
   store.selectNode(d)
 }
 
@@ -905,6 +910,11 @@ function renderWalkedLayer() {
     .style('cursor', 'pointer')
     .on('click', (event, d) => {
       event.stopPropagation()
+      // 锁定状态下点击节点无效果,但提醒用户
+      if (store.lockedHoverNodeId) {
+        shakeLockButton()
+        return
+      }
       store.selectNode(d)
     })
 
@@ -938,9 +948,6 @@ function renderWalkedLayer() {
   setupHoverHandlers()
 }
 
-// 锁定按钮相关
-let showButtonTimer = null  // 延迟显示按钮的定时器
-
 // 在节点文字后面添加锁定按钮(作为独立的 tspan)
 function showLockButton(nodeEl, immediate = false) {
   if (!nodeEl) return
@@ -949,57 +956,48 @@ function showLockButton(nodeEl, immediate = false) {
   const textEl = node.select('text')
   if (textEl.empty()) return
 
+  // 获取当前节点 ID
+  const nodeData = node.datum()
+  const currentNodeId = nodeData?.data?.id || nodeData?.id
+
+  // 判断当前节点是否是已锁定的节点
+  const isThisNodeLocked = store.lockedHoverNodeId && store.lockedHoverNodeId === currentNodeId
+
   // 如果已有按钮,只更新状态
   let btn = textEl.select('.lock-btn')
   if (!btn.empty()) {
-    const isLocked = !!store.lockedHoverNodeId
-    btn.text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
-       .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
-    if (!isLocked) startBreathingAnimation(btn)
+    btn.text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
+       .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
+    if (!isThisNodeLocked) startBreathingAnimation(btn)
     return
   }
 
   // 创建按钮的函数
   const createBtn = () => {
-    // 先清除其他节点的按钮
-    d3.selectAll('.lock-btn').remove()
-
-    const isLocked = !!store.lockedHoverNodeId
+    // 先清除当前 SVG 内其他节点的按钮(不影响另一边)
+    if (svgRef.value) {
+      d3.select(svgRef.value).selectAll('.lock-btn').remove()
+    }
 
     // 添加按钮 tspan(紧跟在文字后面)
     const btn = textEl.append('tspan')
       .attr('class', 'lock-btn')
-      .attr('fill', isLocked ? '#f6ad55' : '#63b3ed')
+      .attr('fill', isThisNodeLocked ? '#f6ad55' : '#63b3ed')
       .attr('font-weight', 'bold')
       .style('cursor', 'pointer')
-      .text(isLocked ? ' 🔓解锁' : ' 🔒锁定')
+      .text(isThisNodeLocked ? ' 🔓解锁' : ' 🔒锁定')
       .on('click', (e) => {
         e.stopPropagation()
         handleLockClick()
       })
 
     // 呼吸灯动画(未锁定时)
-    if (!isLocked) {
+    if (!isThisNodeLocked) {
       startBreathingAnimation(btn)
     }
   }
 
-  // 如果是立即显示,不需要延迟
-  if (immediate) {
-    createBtn()
-    return
-  }
-
-  // 取消之前的显示定时器
-  if (showButtonTimer) {
-    clearTimeout(showButtonTimer)
-  }
-
-  // 延迟 400ms 后显示
-  showButtonTimer = setTimeout(() => {
-    createBtn()
-    showButtonTimer = null
-  }, 400)
+  createBtn()
 }
 
 // 呼吸灯动画(只有按钮部分,蓝色呼吸)
@@ -1021,28 +1019,51 @@ function startBreathingAnimation(btn) {
 
 // 隐藏锁定按钮
 function hideLockButton() {
-  if (showButtonTimer) {
-    clearTimeout(showButtonTimer)
-    showButtonTimer = null
+  if (svgRef.value) {
+    d3.select(svgRef.value).selectAll('.lock-btn').interrupt().remove()
   }
-  d3.selectAll('.lock-btn').interrupt().remove()
+}
+
+// 抖动锁定按钮(提醒用户需要先解锁)
+function shakeLockButton() {
+  d3.selectAll('.lock-btn')
+    .interrupt()
+    .attr('fill', '#fc8181')  // 红色警告
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 3)
+    .transition().duration(50).attr('dx', -3)
+    .transition().duration(50).attr('dx', 0)
+    .transition().duration(200).attr('fill', '#f6ad55')  // 恢复橙色
 }
 
 // 处理锁定按钮点击
 function handleLockClick() {
   const startNodeId = store.selectedNodeId
-  if (store.lockedHoverNodeId) {
+  const currentHoverNodeId = store.hoverNodeId
+
+  // 判断是解锁还是新锁定
+  if (store.lockedHoverNodeId && store.lockedHoverNodeId === currentHoverNodeId) {
+    // 点击的是当前锁定节点的按钮 → 解锁(弹出栈)
     store.clearLockedHover()
-    hideLockButton()
-  } else if (startNodeId) {
-    store.lockCurrentHover(startNodeId)
-    // 更新按钮显示
-    const btn = d3.select('.lock-btn')
-    if (!btn.empty()) {
-      btn.interrupt()
+    // 如果还有上一层锁定,更新按钮状态
+    if (store.lockedHoverNodeId) {
+      d3.selectAll('.lock-btn')
+        .interrupt()
         .text(' 🔓解锁')
         .attr('fill', '#f6ad55')
+    } else {
+      // 完全解锁,清除按钮
+      d3.selectAll('.lock-btn').interrupt().remove()
     }
+  } else if (currentHoverNodeId) {
+    // 点击的是新 hover 节点的按钮 → 锁定新路径(压入栈)
+    store.lockCurrentHover(startNodeId)
+    // 更新按钮状态
+    d3.selectAll('.lock-btn')
+      .interrupt()
+      .text(' 🔓解锁')
+      .attr('fill', '#f6ad55')
   }
 }
 
@@ -1086,16 +1107,36 @@ function setupHoverHandlers() {
       }
     })
     .on('mouseleave', () => {
-      // 如果已锁定,不清除
-      if (store.lockedHoverNodeId) return
+      // 调用 clearHover 恢复状态(如果已锁定会恢复到锁定路径)
       store.clearHover()
-      hideLockButton()
+
+      if (store.lockedHoverNodeId) {
+        // 已锁定:恢复锁定路径高亮,并在锁定节点上显示按钮
+        const svg = d3.select(svgRef.value)
+        const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
+        const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
+        const allLabels = svg.selectAll('.match-score, .walked-score')
+        // 恢复到纯锁定路径高亮(不传 lockedPath,因为这就是唯一的路径)
+        applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, null)
+        // 在锁定节点上显示解锁按钮
+        const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
+        if (lockedNodeInfo?.element) {
+          showLockButton(lockedNodeInfo.element, true)
+        }
+      } else {
+        hideLockButton()
+      }
     })
 }
 
 // 匹配节点点击处理
 function handleMatchNodeClick(event, d) {
   event.stopPropagation()
+  // 锁定状态下点击节点无效果,但提醒用户
+  if (store.lockedHoverNodeId) {
+    shakeLockButton()
+    return
+  }
   store.selectNode(d)
 }
 
@@ -1246,11 +1287,15 @@ function updateHighlight() {
   applyHighlight(svgRef.value, store.highlightedNodeIds, edgeSet, store.selectedNodeId)
 }
 
-// 点击空白取消
+// 点击空白取消(锁定状态下无效果)
 function handleSvgClick(event) {
+  // 锁定状态下,点击空白无效果
+  if (store.lockedHoverNodeId) return
+
   const target = event.target
   if (!target.closest('.tree-node') && !target.closest('.match-node') && !target.closest('.walked-node')) {
     store.clearSelection()
+    hideLockButton()
   }
 }
 
@@ -1278,7 +1323,7 @@ watch(() => store.focusEdgeEndpoints, (endpoints) => {
 watch(() => store.highlightedNodeIds.size, updateHighlight)
 
 // 监听 hover 状态变化(用于左右联动)
-watch(() => store.hoverPathNodes.size, () => {
+watch([() => store.hoverPathNodes.size, () => store.hoverNodeId], () => {
   if (!svgRef.value) return
   const svg = d3.select(svgRef.value)
 
@@ -1287,16 +1332,29 @@ watch(() => store.hoverPathNodes.size, () => {
   const allLabels = svg.selectAll('.match-score, .walked-score')
 
   if (store.hoverPathNodes.size > 0) {
-    // 应用 hover 高亮
-    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes)
+    // 应用 hover 高亮(支持嵌套:传入锁定路径)
+    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
 
     // 如果是从 GraphView 触发的,缩放到显示完整路径
     if (store.hoverSource === 'graph') {
       zoomToPathNodes(store.hoverPathNodes)
     }
+
+    // 在对应节点上显示锁定按钮(无论来源)
+    if (store.hoverNodeId) {
+      const nodeInfo = nodeElements[store.hoverNodeId]
+      if (nodeInfo?.element) {
+        showLockButton(nodeInfo.element)
+      }
+    }
   } else {
     // 清除 hover,恢复原有高亮
     updateHighlight()
+    // 如果没有锁定,隐藏按钮
+    if (!store.lockedHoverNodeId) {
+      hideLockButton()
+    }
   }
 })
 
@@ -1358,6 +1416,28 @@ watch(() => store.selectedPostIndex, (newIdx) => {
   selectedPostIdx.value = newIdx
 })
 
+// 恢复锁定的 hover 状态(重新渲染后调用)
+function restoreLockedHover() {
+  if (!store.lockedHoverNodeId || !svgRef.value) return
+
+  const svg = d3.select(svgRef.value)
+  const allNodes = svg.selectAll('.tree-node, .match-node, .walked-node')
+  const allLinks = svg.selectAll('.tree-link, .match-link, .walked-link')
+  const allLabels = svg.selectAll('.match-score, .walked-score')
+
+  // 恢复高亮效果(传入锁定路径)
+  if (store.hoverPathNodes.size > 0) {
+    const lockedPath = store.lockedHoverPathNodes.size > 0 ? store.lockedHoverPathNodes : null
+    applyHoverHighlight(allNodes, allLinks, allLabels, store.hoverPathNodes, lockedPath)
+  }
+
+  // 恢复锁定按钮
+  const lockedNodeInfo = nodeElements[store.lockedHoverNodeId]
+  if (lockedNodeInfo?.element) {
+    showLockButton(lockedNodeInfo.element, true)
+  }
+}
+
 // 监听布局变化,过渡结束后重新适应视图
 function handleTransitionEnd(e) {
   if (['width', 'height', 'flex', 'flex-grow', 'flex-shrink'].includes(e.propertyName)) {
@@ -1365,7 +1445,10 @@ function handleTransitionEnd(e) {
       renderTree()
       nextTick(() => {
         renderWalkedLayer()  // 重新渲染游走层
-        nextTick(updateHighlight)  // 重新应用高亮状态
+        nextTick(() => {
+          updateHighlight()  // 重新应用高亮状态
+          restoreLockedHover()  // 恢复锁定的 hover 状态
+        })
       })
     })
   }

+ 68 - 30
script/visualization/src/stores/graph.js

@@ -657,32 +657,50 @@ export const useGraphStore = defineStore('graph', () => {
   const hoverPathNodes = ref(new Set())  // hover 路径上的节点集合
   const hoverSource = ref(null)  // hover 来源: 'graph' | 'post-tree'
 
-  // 锁定的 hover 状态
-  const lockedHoverNodeId = ref(null)
-  const lockedHoverPathNodes = ref(new Set())
-  const lockedHoverStartId = ref(null)  // 锁定时的起点
+  // 锁定栈(支持嵌套锁定)
+  const lockedStack = ref([])  // [{nodeId, pathNodes, startId}, ...]
 
-  // 计算 hover 路径(基于已高亮的边)
-  // source: 触发 hover 的模块标识
+  // 获取当前锁定状态(栈顶)
+  const lockedHoverNodeId = computed(() => {
+    const top = lockedStack.value[lockedStack.value.length - 1]
+    return top?.nodeId || null
+  })
+  const lockedHoverPathNodes = computed(() => {
+    const top = lockedStack.value[lockedStack.value.length - 1]
+    return top?.pathNodes || new Set()
+  })
+  const lockedHoverStartId = computed(() => {
+    const top = lockedStack.value[lockedStack.value.length - 1]
+    return top?.startId || null
+  })
+
+  // 计算 hover 路径
+  // 如果有锁定,基于当前锁定路径计算;否则基于全部高亮边
   function computeHoverPath(startId, endId, source = null) {
     if (!startId || !endId || startId === endId) {
       clearHover()
       return
     }
-    // 只在高亮的节点中才响应
-    if (!highlightedNodeIds.value.has(endId)) {
+
+    // 确定搜索范围:锁定状态下在锁定路径内搜索,否则在全部高亮节点内搜索
+    const searchNodes = lockedHoverPathNodes.value.size > 0
+      ? lockedHoverPathNodes.value
+      : highlightedNodeIds.value
+
+    // 目标节点必须在搜索范围内
+    if (!searchNodes.has(endId)) {
       return
     }
 
-    // 获取高亮的边集合
+    // 获取边集合
     const edgeSet = postWalkedEdgeSet.value.size > 0 ? postWalkedEdgeSet.value : walkedEdgeSet.value
     if (edgeSet.size === 0) return
 
-    // 将边集合转换为邻接表
+    // 将边集合转换为邻接表(只包含搜索范围内的节点)
     const adj = new Map()
     for (const edgeKey of edgeSet) {
       const [src, tgt] = edgeKey.split('->')
-      if (src && tgt) {
+      if (src && tgt && searchNodes.has(src) && searchNodes.has(tgt)) {
         if (!adj.has(src)) adj.set(src, [])
         if (!adj.has(tgt)) adj.set(tgt, [])
         adj.get(src).push(tgt)
@@ -690,10 +708,13 @@ export const useGraphStore = defineStore('graph', () => {
       }
     }
 
+    // 确定起点:锁定状态下从锁定路径的起点开始
+    const searchStartId = lockedHoverStartId.value || startId
+
     // BFS 找路径
-    const visited = new Set([startId])
+    const visited = new Set([searchStartId])
     const parent = new Map()
-    const queue = [startId]
+    const queue = [searchStartId]
 
     while (queue.length > 0) {
       const curr = queue.shift()
@@ -724,12 +745,13 @@ export const useGraphStore = defineStore('graph', () => {
     }
   }
 
-  // 清除 hover 状态(如果有锁定则恢复到锁定状态)
+  // 清除 hover 状态(恢复到栈顶锁定状态)
   function clearHover() {
-    if (lockedHoverNodeId.value) {
-      // 恢复到锁定状态
-      hoverNodeId.value = lockedHoverNodeId.value
-      hoverPathNodes.value = new Set(lockedHoverPathNodes.value)
+    if (lockedStack.value.length > 0) {
+      // 恢复到栈顶锁定状态
+      const top = lockedStack.value[lockedStack.value.length - 1]
+      hoverNodeId.value = top.nodeId
+      hoverPathNodes.value = new Set(top.pathNodes)
       hoverSource.value = null
     } else {
       hoverNodeId.value = null
@@ -738,27 +760,40 @@ export const useGraphStore = defineStore('graph', () => {
     }
   }
 
-  // 锁定当前 hover 状态(单击节点时调用
+  // 锁定当前 hover 状态(压入栈
   function lockCurrentHover(startId) {
     if (hoverNodeId.value && hoverPathNodes.value.size > 0) {
-      lockedHoverNodeId.value = hoverNodeId.value
-      lockedHoverPathNodes.value = new Set(hoverPathNodes.value)
-      lockedHoverStartId.value = startId
+      lockedStack.value.push({
+        nodeId: hoverNodeId.value,
+        pathNodes: new Set(hoverPathNodes.value),
+        startId: lockedHoverStartId.value || startId  // 继承之前的起点
+      })
     }
   }
 
-  // 清除锁定的 hover 状态(单击空白时调用
+  // 解锁当前锁定状态(弹出栈顶,恢复到上一层
   function clearLockedHover() {
-    lockedHoverNodeId.value = null
-    lockedHoverPathNodes.value = new Set()
-    lockedHoverStartId.value = null
-    // 同时清除当前 hover
+    if (lockedStack.value.length > 0) {
+      lockedStack.value.pop()
+      // 恢复到新的栈顶状态
+      clearHover()
+    } else {
+      // 栈空,完全清除
+      hoverNodeId.value = null
+      hoverPathNodes.value = new Set()
+      hoverSource.value = null
+    }
+  }
+
+  // 清除所有锁定(完全重置)
+  function clearAllLocked() {
+    lockedStack.value = []
     hoverNodeId.value = null
     hoverPathNodes.value = new Set()
     hoverSource.value = null
   }
 
-  // 清除游走结果(双击空白时调用,保留hover锁定)
+  // 清除游走结果(双击空白时调用)
   function clearWalk() {
     selectedNodeId.value = null
     selectedEdgeId.value = null
@@ -769,8 +804,8 @@ export const useGraphStore = defineStore('graph', () => {
     postWalkedEdges.value = []
     focusNodeId.value = null
     focusEdgeEndpoints.value = null
-    // 同时清除锁定的 hover(因为游走结果没了,hover路径也没意义了)
-    clearLockedHover()
+    // 同时清除所有锁定(因为游走结果没了,hover路径也没意义了)
+    clearAllLocked()
   }
 
   // 计算属性:当前选中节点的数据
@@ -850,12 +885,15 @@ export const useGraphStore = defineStore('graph', () => {
     hoverNodeId,
     hoverPathNodes,
     hoverSource,
+    lockedStack,
     lockedHoverNodeId,
     lockedHoverPathNodes,
+    lockedHoverStartId,
     computeHoverPath,
     clearHover,
     lockCurrentHover,
     clearLockedHover,
+    clearAllLocked,
     clearWalk,
     // 布局
     expandedPanel,

+ 22 - 0
script/visualization/src/style.css

@@ -109,6 +109,7 @@
   .graph-link.dimmed,
   .walked-link.dimmed {
     stroke-opacity: 0.03 !important;
+    pointer-events: none;
   }
 
   /* 分数标签置灰 */
@@ -118,6 +119,27 @@
     opacity: 0.06;
   }
 
+  /* ========== 锁定路径样式(嵌套hover时,锁定路径半透明显示) ========== */
+  .tree-node.locked-path,
+  .match-node.locked-path,
+  .graph-node.locked-path,
+  .walked-node.locked-path {
+    opacity: 0.4;
+  }
+
+  .tree-link.locked-path,
+  .match-link.locked-path,
+  .graph-link.locked-path,
+  .walked-link.locked-path {
+    stroke-opacity: 0.25 !important;
+  }
+
+  .match-score.locked-path,
+  .walked-score.locked-path,
+  .graph-link-label.locked-path {
+    opacity: 0.4;
+  }
+
   /* ========== 统一的高亮样式 ========== */
   .tree-link.highlighted,
   .match-link.highlighted,

+ 48 - 13
script/visualization/src/utils/highlight.js

@@ -74,22 +74,57 @@ export function findPath(startId, endId, links) {
 
 /**
  * 应用 hover 效果:高亮从起点到 hover 节点的路径
+ * 支持嵌套:锁定路径 + 新 hover 路径同时显示
  * @param {D3Selection} nodeSelection - 节点选择集
  * @param {D3Selection} linkSelection - 边选择集
  * @param {D3Selection} labelSelection - 标签选择集(可选)
- * @param {Set} pathNodes - 路径上的节点ID集合
+ * @param {Set} pathNodes - 当前 hover 路径上的节点ID集合
+ * @param {Set} lockedPathNodes - 锁定路径上的节点ID集合(可选)
  */
-export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection, pathNodes) {
-  nodeSelection.classed('dimmed', d => !pathNodes.has(getNodeId(d)))
-  linkSelection.classed('dimmed', l => {
-    const { srcId, tgtId } = getLinkIds(l)
-    return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
-  })
-  if (labelSelection) {
-    labelSelection.classed('dimmed', l => {
+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)
-      return !pathNodes.has(srcId) || !pathNodes.has(tgtId)
+      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
+      })
   }
 }
 
@@ -97,10 +132,10 @@ export function applyHoverHighlight(nodeSelection, linkSelection, labelSelection
  * 清除 hover 效果
  */
 export function clearHoverHighlight(nodeSelection, linkSelection, labelSelection) {
-  nodeSelection.classed('dimmed', false)
-  linkSelection.classed('dimmed', false)
+  nodeSelection.classed('dimmed', false).classed('locked-path', false)
+  linkSelection.classed('dimmed', false).classed('locked-path', false)
   if (labelSelection) {
-    labelSelection.classed('dimmed', false)
+    labelSelection.classed('dimmed', false).classed('locked-path', false)
   }
 }