فهرست منبع

feat: 优化关系图与人设树联动,修复多边显示

- 修复边查找方向为单向(属于/包含沿边方向查找)
- 支持同一对节点间多条边用曲线分开显示
- 修复数据生成中重复"属于"边的问题
- 人设树高亮与关系图配置同步(反选后同步更新)
- 添加"重置"按钮恢复默认配置
- 级联下拉框支持滚动
- "包含"边颜色改为淡粉色,与其他边区分

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 5 روز پیش
والد
کامیت
d54ab25b6c
2فایلهای تغییر یافته به همراه582 افزوده شده و 125 حذف شده
  1. 47 18
      script/data_processing/build_persona_tree.py
  2. 535 107
      script/data_processing/visualize_match_graph.py

+ 47 - 18
script/data_processing/build_persona_tree.py

@@ -96,28 +96,11 @@ def build_persona_tree():
         name = n.get("节点名称", "")
         category_name_to_id[(level, name)] = n["节点ID"]
 
-    # 从分类的"所属分类"字段构建分类之间的层级边(统一用"属于")
-    for n in category_nodes:
-        level = n.get("节点层级", "")
-        parent_names = n.get("所属分类", [])
-        if parent_names:
-            parent_name = parent_names[-1]  # 取最后一个作为直接父分类
-            parent_id = category_name_to_id.get((level, parent_name))
-            if parent_id:
-                tree_edges.append({
-                    "源节点ID": n["节点ID"],
-                    "目标节点ID": parent_id,
-                    "边类型": "属于"
-                })
-
-    # 添加所有原始边(两端节点都在树中的,排除"包含"边因为与"属于"重复)
+    # 先添加所有原始边(两端节点都在树中的)
     for e in all_edges:
         src_id = e["源节点ID"]
         tgt_id = e["目标节点ID"]
         edge_type = e["边类型"]
-        # 跳过"包含"边(与"属于"是反向关系,保留"属于"即可)
-        if edge_type == "包含":
-            continue
         if src_id in node_ids and tgt_id in node_ids:
             tree_edges.append({
                 "源节点ID": src_id,
@@ -126,6 +109,27 @@ def build_persona_tree():
                 "边详情": e.get("边详情", {})
             })
 
+    # 从分类的"所属分类"字段补充分类之间的层级边(如果不存在)
+    for n in category_nodes:
+        level = n.get("节点层级", "")
+        parent_names = n.get("所属分类", [])
+        if parent_names:
+            parent_name = parent_names[-1]  # 取最后一个作为直接父分类
+            parent_id = category_name_to_id.get((level, parent_name))
+            if parent_id:
+                # 检查是否已存在属于边
+                edge_exists = any(
+                    e["源节点ID"] == n["节点ID"] and e["目标节点ID"] == parent_id
+                    and e["边类型"] == "属于"
+                    for e in tree_edges
+                )
+                if not edge_exists:
+                    tree_edges.append({
+                        "源节点ID": n["节点ID"],
+                        "目标节点ID": parent_id,
+                        "边类型": "属于"
+                    })
+
     # 从标签的"所属分类"字段补充标签->分类的边(如果不存在)
     for n in tag_nodes:
         level = n.get("节点层级", "")
@@ -148,6 +152,31 @@ def build_persona_tree():
                         "边详情": {}
                     })
 
+    # 为分类间的"属于"边生成反向的"包含"边
+    # 这样 父分类→子分类 也有边,查询"包含"时可以找到子分类
+    category_ids = set(n["节点ID"] for n in category_nodes)
+    contain_edges_to_add = []
+    for e in tree_edges:
+        if e["边类型"] == "属于":
+            src_id = e["源节点ID"]
+            tgt_id = e["目标节点ID"]
+            # 只为分类→分类的属于边生成反向包含边
+            if src_id in category_ids and tgt_id in category_ids:
+                # 检查是否已存在包含边
+                edge_exists = any(
+                    ex["源节点ID"] == tgt_id and ex["目标节点ID"] == src_id
+                    and ex["边类型"] == "包含"
+                    for ex in tree_edges
+                )
+                if not edge_exists:
+                    contain_edges_to_add.append({
+                        "源节点ID": tgt_id,
+                        "目标节点ID": src_id,
+                        "边类型": "包含",
+                        "边详情": {"说明": "分类层级关系(属于的反向)"}
+                    })
+    tree_edges.extend(contain_edges_to_add)
+
     # 统计各类型边
     tree_edge_counts = {}
     for e in tree_edges:

+ 535 - 107
script/data_processing/visualize_match_graph.py

@@ -111,10 +111,178 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             height: 420px;
             border-top: 1px solid #0f3460;
             display: none;
+            position: relative;
         }}
         #ego-container svg {{
             display: block;
         }}
+        .ego-controls {{
+            position: absolute;
+            top: 5px;
+            left: 5px;
+            right: 5px;
+            z-index: 10;
+            display: flex;
+            gap: 6px;
+            align-items: center;
+            flex-wrap: wrap;
+            font-size: 10px;
+        }}
+        .ego-controls label {{
+            color: #8892b0;
+            display: flex;
+            align-items: center;
+            gap: 3px;
+        }}
+        .ego-controls select {{
+            background: #0f3460;
+            color: #e6e6e6;
+            border: 1px solid #1a1a2e;
+            border-radius: 3px;
+            padding: 3px 6px;
+            font-size: 10px;
+            cursor: pointer;
+        }}
+        .cascade-dropdown {{
+            position: relative;
+        }}
+        .cascade-btn {{
+            background: #0f3460;
+            color: #e6e6e6;
+            border: 1px solid #1a1a2e;
+            border-radius: 3px;
+            padding: 4px 10px;
+            font-size: 10px;
+            cursor: pointer;
+            display: flex;
+            align-items: center;
+            gap: 6px;
+        }}
+        .cascade-btn:hover {{
+            background: #1a4a7a;
+        }}
+        .cascade-btn::after {{
+            content: "▼";
+            font-size: 8px;
+            opacity: 0.6;
+        }}
+        .cascade-menu {{
+            position: absolute;
+            top: 100%;
+            left: 0;
+            background: #0f3460;
+            border: 1px solid #1a1a2e;
+            border-radius: 3px;
+            display: none;
+            z-index: 100;
+            box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+            flex-direction: row;
+        }}
+        .cascade-menu.show {{
+            display: flex;
+        }}
+        .cascade-levels {{
+            border-right: 1px solid #1a1a2e;
+            padding: 4px 0;
+            min-width: 50px;
+        }}
+        .cascade-level-item {{
+            padding: 6px 12px;
+            cursor: pointer;
+            color: #e6e6e6;
+            font-size: 10px;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+        }}
+        .cascade-level-item:hover {{
+            background: rgba(233, 69, 96, 0.2);
+        }}
+        .cascade-level-item.active {{
+            background: rgba(233, 69, 96, 0.3);
+            color: #e94560;
+        }}
+        .cascade-level-item::after {{
+            content: "›";
+            margin-left: 8px;
+            opacity: 0.5;
+        }}
+        .cascade-edges {{
+            padding: 4px 0;
+            min-width: 100px;
+            max-height: 300px;
+            overflow-y: auto;
+        }}
+        .cascade-edge-group {{
+            display: none;
+        }}
+        .cascade-edge-group.active {{
+            display: block;
+        }}
+        .cascade-edge-group-header {{
+            color: #e94560;
+            font-weight: bold;
+            font-size: 9px;
+            padding: 4px 10px 2px;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+        }}
+        .select-all {{
+            font-weight: normal;
+            color: #8892b0;
+            font-size: 9px;
+            cursor: pointer;
+            display: flex;
+            align-items: center;
+            gap: 3px;
+        }}
+        .select-all input {{
+            width: 10px;
+            height: 10px;
+            cursor: pointer;
+        }}
+        .header-actions {{
+            display: flex;
+            align-items: center;
+            gap: 6px;
+        }}
+        .invert-btn, .reset-btn {{
+            color: #8892b0;
+            cursor: pointer;
+            font-size: 9px;
+        }}
+        .invert-btn:hover {{
+            color: #e94560;
+        }}
+        .reset-btn:hover {{
+            color: #2ecc71;
+        }}
+        .cascade-edge-item {{
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 4px 10px;
+            cursor: pointer;
+            color: #e6e6e6;
+            font-size: 10px;
+        }}
+        .cascade-edge-item:hover {{
+            background: rgba(233, 69, 96, 0.15);
+        }}
+        .cascade-edge-item input {{
+            display: none;
+        }}
+        .cascade-edge-item .edge-color {{
+            width: 12px;
+            height: 12px;
+            border-radius: 2px;
+            border: 1px solid #333;
+            opacity: 0.3;
+        }}
+        .cascade-edge-item input:checked + .edge-color {{
+            opacity: 1;
+        }}
         #graph {{
             flex: 1;
             position: relative;
@@ -586,7 +754,48 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         <div class="main-content">
             <div id="left-panel">
                 <div id="tree-container"></div>
-                <div id="ego-container"></div>
+                <div id="ego-container">
+                    <div class="ego-controls">
+                        <div class="cascade-dropdown">
+                            <div class="cascade-btn" onclick="toggleCascadeMenu()">
+                                <span id="cascade-label">1级</span>
+                            </div>
+                            <div class="cascade-menu" id="cascade-menu">
+                                <div class="cascade-levels">
+                                    <div class="cascade-level-item active" data-level="1" onclick="selectLevel(1)">1级</div>
+                                    <div class="cascade-level-item" data-level="2" onclick="selectLevel(2)">2级</div>
+                                    <div class="cascade-level-item" data-level="3" onclick="selectLevel(3)">3级</div>
+                                </div>
+                                <div class="cascade-edges">
+                                    <div class="cascade-edge-group active" data-level="1">
+                                        <div class="cascade-edge-group-header">L1 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="1" data-select-all checked>全选</label><span class="invert-btn" data-level="1" onclick="invertSelection(1)">反选</span><span class="reset-btn" onclick="resetSelection()">重置</span></span></div>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="属于" checked><span class="edge-color" style="background:#9b59b6"></span>属于</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="包含" checked><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="分类共现(跨点)" checked><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="分类共现(点内)" checked><span class="edge-color" style="background:#3498db"></span>点内共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="标签共现" checked><span class="edge-color" style="background:#f39c12"></span>标签共现</label>
+                                    </div>
+                                    <div class="cascade-edge-group" data-level="2">
+                                        <div class="cascade-edge-group-header">L2 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="2" data-select-all>全选</label><span class="invert-btn" data-level="2" onclick="invertSelection(2)">反选</span></span></div>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="属于"><span class="edge-color" style="background:#9b59b6"></span>属于</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="包含" checked><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="分类共现(跨点)"><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="分类共现(点内)"><span class="edge-color" style="background:#3498db"></span>点内共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="标签共现"><span class="edge-color" style="background:#f39c12"></span>标签共现</label>
+                                    </div>
+                                    <div class="cascade-edge-group" data-level="3">
+                                        <div class="cascade-edge-group-header">L3 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="3" data-select-all>全选</label><span class="invert-btn" data-level="3" onclick="invertSelection(3)">反选</span></span></div>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="属于"><span class="edge-color" style="background:#9b59b6"></span>属于</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="包含" checked><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="分类共现(跨点)"><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="分类共现(点内)"><span class="edge-color" style="background:#3498db"></span>点内共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="标签共现"><span class="edge-color" style="background:#f39c12"></span>标签共现</label>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
             </div>
             <div id="graph">
                 <div class="tooltip" id="tooltip"></div>
@@ -766,6 +975,47 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 tab.addEventListener("click", () => switchTab(index));
             }});
 
+            // 绑定关系图控制面板事件
+            // 全选复选框事件
+            document.querySelectorAll('.select-all input').forEach(cb => {{
+                cb.addEventListener('change', (e) => {{
+                    const level = e.target.dataset.level;
+                    const checked = e.target.checked;
+                    // 设置该层级所有边类型复选框
+                    document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(item => {{
+                        item.checked = checked;
+                    }});
+                    // 重新渲染关系图和人设树高亮
+                    if (currentEgoCenterNodeId) {{
+                        handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
+                    }}
+                }});
+            }});
+
+            // 边类型复选框变化事件
+            document.querySelectorAll('.cascade-edge-item input').forEach(cb => {{
+                cb.addEventListener('change', (e) => {{
+                    // 更新全选复选框状态
+                    const level = e.target.dataset.level;
+                    const allItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`);
+                    const checkedItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]:checked`);
+                    const selectAllCb = document.querySelector(`.select-all input[data-level="${{level}}"]`);
+                    selectAllCb.checked = allItems.length === checkedItems.length;
+
+                    // 重新渲染关系图和人设树高亮
+                    if (currentEgoCenterNodeId) {{
+                        handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
+                    }}
+                }});
+            }});
+
+            // 点击页面其他地方关闭级联菜单
+            document.addEventListener('click', (e) => {{
+                if (!e.target.closest('.cascade-dropdown')) {{
+                    document.getElementById('cascade-menu').classList.remove('show');
+                }}
+            }});
+
             // 显示第一个帖子
             switchTab(0);
         }}
@@ -1461,14 +1711,6 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("class", "ego-center")
                 .attr("transform", `translate(200, 210)`);
 
-            // 关系图背景圆
-            egoCenterGroup.append("circle")
-                .attr("class", "ego-background")
-                .attr("r", egoDisplayRadius)
-                .attr("fill", "rgba(233, 69, 96, 0.08)")
-                .attr("stroke", "rgba(233, 69, 96, 0.3)")
-                .attr("stroke-width", 2);
-
             // 关系图标题
             egoCenterGroup.append("text")
                 .attr("class", "ego-title")
@@ -1520,7 +1762,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const treeEdgeColors = {{
                 "分类层级": "#2ecc71",       // 绿色 - 分类的层级关系
                 "属于": "#9b59b6",           // 紫色 - 标签属于分类
-                "包含": "#8e44ad",           // 深紫 - 分类包含标签
+                "包含": "#ffb6c1",           // 淡粉 - 分类包含标签(向下)
                 "分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
                 "分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
                 "标签共现": "#f39c12"        // 橙色 - 标签共现
@@ -1534,27 +1776,29 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 "标签共现": "2,2"
             }};
 
-            // 只保留"属于"边用于树的展示(其他边用专门子图展示)
-            const visibleTreeEdges = personaTreeData.edges.filter(e => {{
-                return e.边类型 === "属于" && treeNodeById[e.源节点ID] && treeNodeById[e.目标节点ID];
-            }});
+            // 只用D3树布局的链接作为树边(避免重复)
+            const visibleTreeEdges = root.links().map(link => ({{
+                源节点ID: link.source.data.节点ID || link.source.data.name,
+                目标节点ID: link.target.data.节点ID || link.target.data.name,
+                边类型: "属于",
+                isTreeLink: true,
+                sourceNode: link.source,
+                targetNode: link.target
+            }}));
 
-            // 添加树结构边(根节点->维度,维度->子节点)
-            root.links().forEach(link => {{
-                visibleTreeEdges.push({{
-                    源节点ID: link.source.data.节点ID || link.source.data.name,
-                    目标节点ID: link.target.data.节点ID || link.target.data.name,
-                    边类型: "属于",
-                    isTreeLink: true,
-                    sourceNode: link.source,
-                    targetNode: link.target
-                }});
-            }});
+            // 生成树边路径的函数
+            function treeEdgePath(d) {{
+                const source = d.sourceNode;
+                const target = d.targetNode;
+                if (!source || !target) return "";
+                const midX = (source.y + target.y) / 2;
+                return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
+            }}
 
-            // 绘制原始边(分类层级、标签属于
+            // 绘制原始边(只有属于边)
             const treeEdgeGroup = treeGroup.append("g").attr("class", "tree-edges");
 
-            // 先绘制可见边
+            // 先绘制可见边(只有属于边)
             const treeEdges = treeEdgeGroup.selectAll(".tree-edge")
                 .data(visibleTreeEdges)
                 .join("path")
@@ -1563,13 +1807,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("stroke", "#9b59b6")  // 属于边用紫色
                 .attr("stroke-opacity", 0.3)
                 .attr("stroke-width", 1)
-                .attr("d", d => {{
-                    const source = d.isTreeLink ? d.sourceNode : treeNodeById[d.源节点ID];
-                    const target = d.isTreeLink ? d.targetNode : treeNodeById[d.目标节点ID];
-                    if (!source || !target) return "";
-                    const midX = (source.y + target.y) / 2;
-                    return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
-                }});
+                .attr("d", treeEdgePath);
 
             // 绘制透明的热区边(便于点击)
             const treeEdgeHitareas = treeEdgeGroup.selectAll(".tree-edge-hitarea")
@@ -1580,13 +1818,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("stroke", "transparent")
                 .attr("stroke-width", 12)
                 .style("cursor", "pointer")
-                .attr("d", d => {{
-                    const source = d.isTreeLink ? d.sourceNode : treeNodeById[d.源节点ID];
-                    const target = d.isTreeLink ? d.targetNode : treeNodeById[d.目标节点ID];
-                    if (!source || !target) return "";
-                    const midX = (source.y + target.y) / 2;
-                    return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
-                }})
+                .attr("d", treeEdgePath)
                 .on("click", (event, d) => {{
                     event.stopPropagation();
                     // 调用共享的边点击处理函数
@@ -2400,44 +2632,19 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
             }});
 
-            // 查找所有与该节点相关的边
-            const allConnectedEdges = personaTreeData.edges.filter(e =>
-                e.源节点ID === clickedNodeId || e.目标节点ID === clickedNodeId
-            );
-
-            // 收集所有相关节点ID
-            const connectedNodeIds = new Set();
-            connectedNodeIds.add(clickedNodeId);
-            allConnectedEdges.forEach(e => {{
-                connectedNodeIds.add(e.源节点ID);
-                connectedNodeIds.add(e.目标节点ID);
-            }});
+            // 使用多级配置收集关联节点(与关系图一致)
+            const levelConfigs = getLevelConfigs();
+            const {{ nodeIds: connectedNodeIds, edges: allConnectedEdges }} = collectNodesAndEdges(clickedNodeId, levelConfigs);
 
             // 获取D3树节点
             const clickedD3Node = treeNodeById[clickedNodeId];
 
-            // 也添加树结构中的父子节点
-            if (clickedD3Node) {{
-                if (clickedD3Node.parent && clickedD3Node.parent.data.节点ID) {{
-                    connectedNodeIds.add(clickedD3Node.parent.data.节点ID);
-                }}
-                if (clickedD3Node.children) {{
-                    clickedD3Node.children.forEach(c => {{
-                        if (c.data.节点ID) connectedNodeIds.add(c.data.节点ID);
-                    }});
-                }}
-            }}
-
-            // 转换为D3节点集合
+            // 转换为D3节点集合(只基于 collectNodesAndEdges 的结果,与关系图同步)
             const connectedD3Nodes = new Set();
             if (clickedD3Node) connectedD3Nodes.add(clickedD3Node);
             connectedNodeIds.forEach(id => {{
                 if (treeNodeById[id]) connectedD3Nodes.add(treeNodeById[id]);
             }});
-            if (clickedD3Node) {{
-                if (clickedD3Node.parent) connectedD3Nodes.add(clickedD3Node.parent);
-                if (clickedD3Node.children) clickedD3Node.children.forEach(c => connectedD3Nodes.add(c));
-            }}
 
             // 高亮树中的节点
             treeGroup.selectAll(".tree-node")
@@ -2462,11 +2669,14 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     return (n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb";
                 }});
 
-            // 边高亮
+            // 边高亮(树边基于连接的节点)
             treeEdges.attr("stroke-opacity", function(e) {{
-                const isConnected = (e.源节点ID === clickedNodeId || e.目标节点ID === clickedNodeId) ||
-                    (e.isTreeLink && (e.源节点ID === clickedNodeName || e.目标节点ID === clickedNodeName));
-                return isConnected ? 0.9 : 0.15;
+                // 树边高亮条件:子节点(目标)在连接集中,或者两端都在连接集中
+                const srcId = e.源节点ID;
+                const tgtId = e.目标节点ID;
+                const srcConnected = connectedNodeIds.has(srcId) || connectedD3Nodes.has(e.sourceNode);
+                const tgtConnected = connectedNodeIds.has(tgtId) || connectedD3Nodes.has(e.targetNode);
+                return (srcConnected && tgtConnected) ? 0.9 : 0.15;
             }});
 
             // 计算入边和出边
@@ -2654,8 +2864,149 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
         // 关系子图(Ego Graph)- 在画布第四层显示
         let currentEgoSimulation = null;  // 保存当前的力模拟,用于停止
+        let currentEgoCenterNodeId = null;  // 当前关系图中心节点ID
+        let currentEgoCenterNodeName = null;  // 当前关系图中心节点名称
+
+        // 当前选中的层级数
+        let currentLevelCount = 1;
+
+        // 切换级联菜单
+        function toggleCascadeMenu() {{
+            event.stopPropagation();
+            const menu = document.getElementById('cascade-menu');
+            menu.classList.toggle('show');
+        }}
+
+        // 反选指定层级的边类型
+        function invertSelection(level) {{
+            event.stopPropagation();
+            document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(cb => {{
+                cb.checked = !cb.checked;
+            }});
+            // 更新全选复选框状态
+            const allItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`);
+            const checkedItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]:checked`);
+            const selectAllCb = document.querySelector(`.select-all input[data-level="${{level}}"]`);
+            selectAllCb.checked = allItems.length === checkedItems.length;
+            // 重新渲染
+            if (currentEgoCenterNodeId) {{
+                handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
+            }}
+        }}
+
+        // 重置所有层级到默认配置(1级全选,2/3级只选包含)
+        function resetSelection() {{
+            event.stopPropagation();
+            // Level 1: 全选
+            document.querySelectorAll('.cascade-edge-item input[data-level="1"]').forEach(cb => {{
+                cb.checked = true;
+            }});
+            document.querySelector('.select-all input[data-level="1"]').checked = true;
+
+            // Level 2 & 3: 只选包含
+            [2, 3].forEach(level => {{
+                document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(cb => {{
+                    cb.checked = (cb.dataset.type === "包含");
+                }});
+                document.querySelector(`.select-all input[data-level="${{level}}"]`).checked = false;
+            }});
+
+            // 重新渲染
+            if (currentEgoCenterNodeId) {{
+                handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
+            }}
+        }}
+
+        // 选择层级
+        function selectLevel(level) {{
+            event.stopPropagation();
+            currentLevelCount = level;
+            // 更新层级选中状态
+            document.querySelectorAll('.cascade-level-item').forEach(item => {{
+                const itemLevel = parseInt(item.dataset.level);
+                item.classList.toggle('active', itemLevel === level);
+            }});
+            // 显示对应的边类型组(1级显示L1,2级显示L1+L2,3级显示L1+L2+L3)
+            document.querySelectorAll('.cascade-edge-group').forEach(group => {{
+                const groupLevel = parseInt(group.dataset.level);
+                group.classList.toggle('active', groupLevel <= level);
+            }});
+            // 更新按钮显示
+            document.getElementById('cascade-label').textContent = level + '级';
+            // 重新渲染关系图和人设树高亮
+            if (currentEgoCenterNodeId) {{
+                handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
+            }}
+        }}
+
+        // 获取指定层级选中的边类型
+        function getLevelEdgeTypes(level) {{
+            const types = [];
+            document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]:checked`).forEach(cb => {{
+                types.push(cb.dataset.type);
+            }});
+            return types;
+        }}
+
+        // 获取层级配置(返回每层的边类型数组)
+        function getLevelConfigs() {{
+            const configs = [];
+            for (let i = 1; i <= currentLevelCount; i++) {{
+                const types = getLevelEdgeTypes(i);
+                configs.push(types);
+            }}
+            return configs;
+        }}
+
+        // 多层级展开:收集指定层级内的所有节点和边(每层可指定不同边类型)
+        function collectNodesAndEdges(centerNodeId, levelConfigs) {{
+            const nodeIds = new Set([centerNodeId]);
+            const collectedEdges = [];
+            const visitedEdges = new Set();
+
+            let currentLayer = new Set([centerNodeId]);
+
+            for (let d = 0; d < levelConfigs.length; d++) {{
+                const edgeTypes = levelConfigs[d];
+                if (edgeTypes.length === 0) break;
+
+                const nextLayer = new Set();
+
+                personaTreeData.edges.forEach(e => {{
+                    // 筛选边类型
+                    if (!edgeTypes.includes(e.边类型)) return;
+
+                    const edgeKey = `${{e.源节点ID}}-${{e.目标节点ID}}-${{e.边类型}}`;
+                    if (visitedEdges.has(edgeKey)) return;
+
+                    // 单向查找:只从源节点出发,沿边的方向查找
+                    // 属于:子→父(从子出发找父)
+                    // 包含:父→子(从父出发找子)
+                    // 共现:A→B(从A出发找B)
+                    if (currentLayer.has(e.源节点ID)) {{
+                        visitedEdges.add(edgeKey);
+                        collectedEdges.push({{...e, _level: d + 1}});
+
+                        // 添加目标节点到下一层
+                        if (!nodeIds.has(e.目标节点ID)) {{
+                            nextLayer.add(e.目标节点ID);
+                            nodeIds.add(e.目标节点ID);
+                        }}
+                    }}
+                }});
+
+                currentLayer = nextLayer;
+                if (nextLayer.size === 0) break;
+            }}
+
+            return {{ nodeIds, edges: collectedEdges, depth: levelConfigs.length }};
+        }}
 
         function renderEgoGraph(centerNodeId, centerNodeName) {{
+            // 保存当前中心节点
+            currentEgoCenterNodeId = centerNodeId;
+            currentEgoCenterNodeName = centerNodeName;
+
             // 显示关系图容器
             document.getElementById("ego-container").style.display = "block";
 
@@ -2672,28 +3023,22 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 清除旧内容
             egoGroup.selectAll("*").remove();
 
+            // 获取当前层级配置
+            const levelConfigs = getLevelConfigs();
+
             // 更新标题
             d3.select(".ego-container .ego-title").text(`关系图: ${{centerNodeName}}`);
 
             // 获取层半径
             const radius = 160;  // 固定半径
 
-            // 找到所有相关的边
-            const relatedEdges = personaTreeData.edges.filter(e =>
-                e.源节点ID === centerNodeId || e.目标节点ID === centerNodeId
-            );
-
-            // 收集所有相关节点ID
-            const nodeIds = new Set([centerNodeId]);
-            relatedEdges.forEach(e => {{
-                nodeIds.add(e.源节点ID);
-                nodeIds.add(e.目标节点ID);
-            }});
+            // 根据层级配置收集节点和边
+            const {{ nodeIds, edges: relatedEdges, depth }} = collectNodesAndEdges(centerNodeId, levelConfigs);
 
-            // 构建节点数据
+            // 构建节点数据(确保中心节点始终包含)
             const nodeMap = {{}};
             personaTreeData.nodes.forEach(n => {{
-                if (nodeIds.has(n.节点ID)) {{
+                if (nodeIds.has(n.节点ID) || n.节点ID === centerNodeId) {{
                     nodeMap[n.节点ID] = {{
                         id: n.节点ID,
                         name: n.节点名称,
@@ -2705,11 +3050,35 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             }});
 
             const nodes = Object.values(nodeMap);
-            const links = relatedEdges.map(e => ({{
-                source: e.源节点ID,
-                target: e.目标节点ID,
-                type: e.边类型
-            }}));
+
+            // 处理同一对节点间的多条边,分配不同弧度
+            const linksByPair = {{}};
+            relatedEdges.forEach(e => {{
+                // 用排序后的节点对作为key,确保A-B和B-A是同一对
+                const pairKey = [e.源节点ID, e.目标节点ID].sort().join("||");
+                if (!linksByPair[pairKey]) linksByPair[pairKey] = [];
+                linksByPair[pairKey].push(e);
+            }});
+
+            // 为每条边分配弧度索引
+            const links = [];
+            Object.values(linksByPair).forEach(pairEdges => {{
+                const count = pairEdges.length;
+                pairEdges.forEach((e, i) => {{
+                    // 弧度:0表示直线,正负值表示向两侧弯曲
+                    let curvature = 0;
+                    if (count > 1) {{
+                        // 多条边时,均匀分布弧度
+                        curvature = (i - (count - 1) / 2) * 0.3;
+                    }}
+                    links.push({{
+                        source: e.源节点ID,
+                        target: e.目标节点ID,
+                        type: e.边类型,
+                        curvature: curvature
+                    }});
+                }});
+            }});
 
             // 根据节点数量动态计算实际使用的半径
             const nodeCount = nodes.length;
@@ -2725,7 +3094,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 actualRadius = Math.max(minEgoRadius, Math.min(maxEgoRadius, calcR));
             }}
 
-            // 显示节点名称作为标题
+            // 显示节点名称作为标题(包含层级和节点数)
             egoGroup.append("text")
                 .attr("class", "ego-title")
                 .attr("y", -actualRadius - 15)
@@ -2733,21 +3102,59 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("fill", "#e94560")
                 .attr("font-size", "12px")
                 .attr("font-weight", "bold")
-                .text(centerNodeName + ` (${{nodeCount}}个节点)`);
+                .text(`${{centerNodeName}} (${{depth}}级, ${{nodeCount}}节点, ${{relatedEdges.length}}边)`);
+
+            // 如果只有中心节点,只渲染中心节点
+            if (nodes.length === 1) {{
+                const centerNode = nodes[0];
+                // 获取中心节点的维度颜色
+                const dimColors = {{
+                    "灵感点": "#f39c12",
+                    "目的点": "#3498db",
+                    "关键点": "#9b59b6"
+                }};
+                const level = centerNode.level || "";
+                let fillColor = "#888";
+                if (level.includes("灵感点")) fillColor = dimColors["灵感点"];
+                else if (level.includes("目的点")) fillColor = dimColors["目的点"];
+                else if (level.includes("关键点")) fillColor = dimColors["关键点"];
+
+                // 绘制中心节点
+                const nodeGroup = egoGroup.append("g")
+                    .attr("class", "ego-node-group")
+                    .attr("transform", "translate(0, 0)");
+
+                nodeGroup.append("circle")
+                    .attr("r", 22)
+                    .attr("fill", fillColor)
+                    .attr("stroke", "#fff")
+                    .attr("stroke-width", 3)
+                    .attr("cursor", "pointer")
+                    .style("filter", "drop-shadow(0 2px 4px rgba(0,0,0,0.3))");
+
+                nodeGroup.append("text")
+                    .attr("dy", "0.35em")
+                    .attr("text-anchor", "middle")
+                    .attr("fill", "#fff")
+                    .attr("font-size", "10px")
+                    .attr("font-weight", "bold")
+                    .attr("pointer-events", "none")
+                    .text(centerNode.name.length > 4 ? centerNode.name.slice(0, 4) + "..." : centerNode.name);
 
-            // 如果没有相关节点,显示提示
-            if (nodes.length <= 1) {{
+                // 显示提示文字
                 egoGroup.append("text")
+                    .attr("y", 50)
                     .attr("text-anchor", "middle")
                     .attr("fill", "rgba(255,255,255,0.4)")
-                    .attr("font-size", "11px")
-                    .text("该节点没有相关边");
+                    .attr("font-size", "10px")
+                    .text("相关边");
                 return;
             }}
 
             // 边类型颜色(统一用实线)
             const edgeColors = {{
                 "属于": "#9b59b6",           // 紫色 - 层级关系
+                "包含": "#ffb6c1",           // 淡粉 - 分类包含标签(向下)
                 "分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
                 "分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
                 "标签共现": "#f39c12"         // 橙色 - 标签共现
@@ -2814,14 +3221,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             currentEgoSimulation = simulation;
 
-            // 绘制边(统一用实线
+            // 绘制边(用曲线支持多边
             const link = egoGroup.selectAll(".ego-edge")
                 .data(links)
-                .join("line")
+                .join("path")
                 .attr("class", "ego-edge")
                 .attr("stroke", d => edgeColors[d.type] || "#666")
                 .attr("stroke-width", 1.5)
-                .attr("stroke-opacity", 0.7);
+                .attr("stroke-opacity", 0.7)
+                .attr("fill", "none");
 
             // 绘制节点(分类用方形,标签用圆形)
             const node = egoGroup.selectAll(".ego-node")
@@ -2880,14 +3288,33 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 handleEdgeClick(d.source.id, d.target.id, d.type);
             }});
 
+            // 生成曲线路径的函数
+            function linkPath(d) {{
+                const sx = d.source.x, sy = d.source.y;
+                const tx = d.target.x, ty = d.target.y;
+
+                if (d.curvature === 0) {{
+                    // 直线
+                    return `M${{sx}},${{sy}}L${{tx}},${{ty}}`;
+                }} else {{
+                    // 曲线:计算控制点
+                    const dx = tx - sx, dy = ty - sy;
+                    const dist = Math.sqrt(dx * dx + dy * dy);
+                    // 控制点垂直于连线方向偏移
+                    const mx = (sx + tx) / 2;
+                    const my = (sy + ty) / 2;
+                    const offset = d.curvature * dist * 0.5;
+                    // 垂直方向单位向量
+                    const nx = -dy / dist, ny = dx / dist;
+                    const cx = mx + nx * offset;
+                    const cy = my + ny * offset;
+                    return `M${{sx}},${{sy}}Q${{cx}},${{cy}} ${{tx}},${{ty}}`;
+                }}
+            }}
+
             // 更新位置
             simulation.on("tick", () => {{
-                link
-                    .attr("x1", d => d.source.x)
-                    .attr("y1", d => d.source.y)
-                    .attr("x2", d => d.target.x)
-                    .attr("y2", d => d.target.y);
-
+                link.attr("d", linkPath);
                 node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
             }});
         }}
@@ -2931,6 +3358,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 边类型颜色
             const edgeColors = {{
                 "属于": "#9b59b6",
+                "包含": "#ffb6c1",
                 "分类共现(跨点)": "#2ecc71",
                 "分类共现(点内)": "#3498db",
                 "标签共现": "#f39c12"