Browse Source

feat: 优化跨层边显示逻辑和默认可见性

- 修复跨层边收集:不再跳过属于边,收集所有跨层边类型
- 右侧1-2层之间只显示属于边,过滤掉包含边(避免重复)
- 多边曲线偏移:同一对节点间不同类型边用不同弧度显示
- 同类型边去重:同一对节点间相同类型边只保留一条
- 边直接引用节点对象:边绑定节点位置,跟随节点移动
- 树边同步更新:spreadTagPositions后同步更新树边路径
- 默认隐藏:左侧圆层级边和匹配边默认隐藏,需要时可勾选显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 4 days ago
parent
commit
46839035c8

+ 1 - 5
script/data_processing/build_match_graph.py

@@ -645,15 +645,11 @@ def process_filtered_result(
     useful_category_edges = [e for e in category_edges
                             if e["源节点ID"] in useful_expanded_ids and e["目标节点ID"] in useful_expanded_ids]
 
-    # 5. 获取直接匹配层(第2层)和扩展层(第3层)之间的所有边(不仅仅是属于边)
+    # 5. 获取直接匹配层(第2层)和扩展层(第3层)之间的所有跨层
     # 这些边连接了直接匹配的人设节点和扩展的分类节点
     cross_layer_edges = []
     for edge in edges_data.get("边列表", []):
         src, tgt = edge["源节点ID"], edge["目标节点ID"]
-        edge_type = edge["边类型"]
-        # 跳过已经收集的属于边(避免重复)
-        if edge_type == "属于":
-            continue
         # 一端在直接匹配层,另一端在扩展层
         src_in_direct = src in persona_node_ids
         src_in_expanded = src in useful_expanded_ids

+ 232 - 46
script/data_processing/visualize_match_graph.py

@@ -958,17 +958,17 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                                 </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 class="cascade-edge-group-header">L1 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="1" data-select-all>全选</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="属于"><span class="edge-color" style="background:#9b59b6"></span>属于</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="包含"><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="分类共现(跨点)"><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="分类共现(点内)"><span class="edge-color" style="background:#3498db"></span>点内共现</label>
+                                        <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="标签共现"><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:#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>
@@ -976,7 +976,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                                     <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:#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>
@@ -2045,9 +2045,21 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             if (postTree && postTree.root) {{
                 // D3树布局(只用于帖子→维度→点→标签)
+                // separation 根据节点名称长度动态计算
                 const postTreeLayout = d3.tree()
                     .nodeSize([50, 80])
-                    .separation((a, b) => a.parent === b.parent ? 1.2 : 1.5);
+                    .separation((a, b) => {{
+                        const nameA = a.data.name || '';
+                        const nameB = b.data.name || '';
+                        const charWidth = 9;
+                        const baseGap = 30;
+                        // 根据两个节点的文字长度计算间距
+                        const widthA = nameA.length * charWidth / 2;
+                        const widthB = nameB.length * charWidth / 2;
+                        const minSep = (widthA + widthB + baseGap) / 50;  // 除以 nodeSize[0]
+                        const defaultSep = a.parent === b.parent ? 1.2 : 1.5;
+                        return Math.max(minSep, defaultSep);
+                    }});
 
                 // 过滤掉人设节点,只保留树结构
                 function filterTreeData(node) {{
@@ -2082,10 +2094,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     }} else if (d.data.nodeType === "标签") {{
                         d.y = targetTagY;
                         // 记录标签节点位置(同一个ID可能有多个位置)
+                        // 保存 d 的引用,以便后续 spreadTagPositions 可以更新节点位置
                         if (!tagNodePositions[d.data.id]) {{
                             tagNodePositions[d.data.id] = [];
                         }}
-                        tagNodePositions[d.data.id].push({{ x: d.x, y: d.y, data: d.data }});
+                        tagNodePositions[d.data.id].push({{ x: d.x, y: d.y, data: d.data, node: d }});
                     }}
                 }});
 
@@ -2229,6 +2242,79 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     }});
                 }});
 
+                // 对帖子标签层应用防重叠(展平后处理)
+                function spreadTagPositions(tagPositions, baseGap = 30, charWidth = 9) {{
+                    // 展平所有标签位置
+                    const allTags = [];
+                    Object.entries(tagPositions).forEach(([id, posArray]) => {{
+                        posArray.forEach((pos, idx) => {{
+                            const name = pos.data ? (pos.data.name || '') : '';
+                            allTags.push({{ id, idx, x: pos.x, y: pos.y, nameLen: name.length, pos }});
+                        }});
+                    }});
+
+                    // 按y分层
+                    const layers = {{}};
+                    allTags.forEach(tag => {{
+                        const y = tag.y;
+                        if (!layers[y]) layers[y] = [];
+                        layers[y].push(tag);
+                    }});
+
+                    // 对每层进行防重叠处理
+                    Object.values(layers).forEach(layerTags => {{
+                        if (layerTags.length < 2) return;
+
+                        // 按x排序
+                        layerTags.sort((a, b) => a.x - b.x);
+
+                        // 多轮迭代
+                        for (let iter = 0; iter < 5; iter++) {{
+                            let hasOverlap = false;
+                            for (let i = 1; i < layerTags.length; i++) {{
+                                const prev = layerTags[i - 1];
+                                const curr = layerTags[i];
+
+                                const prevHalfWidth = (prev.nameLen * charWidth) / 2 + 10;
+                                const currHalfWidth = (curr.nameLen * charWidth) / 2 + 10;
+                                const minGap = prevHalfWidth + currHalfWidth + baseGap;
+
+                                const gap = curr.x - prev.x;
+
+                                if (gap < minGap) {{
+                                    hasOverlap = true;
+                                    const shift = (minGap - gap) / 2;
+                                    prev.x -= shift;
+                                    curr.x += shift;
+
+                                    // 更新原始位置
+                                    prev.pos.x = prev.x;
+                                    curr.pos.x = curr.x;
+                                    // 同步更新节点对象的 x(用于后续更新 DOM 位置)
+                                    if (prev.pos.node) prev.pos.node.x = prev.x - postTreeOffsetX;
+                                    if (curr.pos.node) curr.pos.node.x = curr.x - postTreeOffsetX;
+                                }}
+                            }}
+                            if (!hasOverlap) break;
+                        }}
+                    }});
+                }}
+
+                spreadTagPositions(tagNodePositions, 25, 9);
+
+                // 更新标签节点的 DOM 位置(根据 spreadTagPositions 调整后的 d.x)
+                treeNodes.filter(d => d.data.nodeType === "标签")
+                    .attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+
+                // 更新树边的路径(因为标签节点位置可能变了)
+                treeLinks.attr("d", d => {{
+                    const sourceY = d.source.data.isRoot ? d.source.y + cardHeight/2 : d.source.y;
+                    return `M${{d.source.x}},${{sourceY}}
+                            C${{d.source.x}},${{(sourceY + d.target.y) / 2}}
+                             ${{d.target.x}},${{(sourceY + d.target.y) / 2}}
+                             ${{d.target.x}},${{d.target.y}}`;
+                }});
+
                 // ===== 下半部分:三分图(标签 ↔ 人设_1层 ↔ 人设_2层)=====
                 // 复用圆形图的节点数据,但只取人设节点(去重)
                 const personaLayer1Nodes = nodes.filter(n => getNodeLayer(n) === 1);
@@ -2238,7 +2324,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 const crossLayerLinks = links.filter(link => {{
                     const srcLayer = getNodeLayer(link.source);
                     const tgtLayer = getNodeLayer(link.target);
-                    return srcLayer !== tgtLayer;
+                    if (srcLayer === tgtLayer) return false;
+                    // 1-2层之间只显示"属于"边(从上到下),过滤掉"包含"边(从下到上)
+                    if ((srcLayer === 1 && tgtLayer === 2) || (srcLayer === 2 && tgtLayer === 1)) {{
+                        if (link.type === "包含") return false;
+                    }}
+                    return true;
                 }});
 
                 console.log("层间边数:", crossLayerLinks.length, "人设1层:", personaLayer1Nodes.length, "人设2层:", personaLayer2Nodes.length);
@@ -2300,8 +2391,65 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     }}
                 }});
 
+                // 防重叠:分散同层中x坐标太近的节点(根据文字长度动态计算间距)
+                function spreadOverlappingNodes(positions, baseGap = 40, charWidth = 8) {{
+                    // 按y分层
+                    const layers = {{}};
+                    Object.entries(positions).forEach(([id, pos]) => {{
+                        const y = pos.y;
+                        if (!layers[y]) layers[y] = [];
+                        // 获取节点名称长度
+                        const name = pos.node ? (pos.node.节点名称 || pos.node.name || '') : '';
+                        layers[y].push({{ id, ...pos, nameLen: name.length }});
+                    }});
+
+                    // 对每层进行防重叠处理
+                    Object.values(layers).forEach(layerNodes => {{
+                        if (layerNodes.length < 2) return;
+
+                        // 按x排序
+                        layerNodes.sort((a, b) => a.x - b.x);
+
+                        // 多轮迭代确保所有节点都不重叠
+                        for (let iter = 0; iter < 5; iter++) {{
+                            let hasOverlap = false;
+                            for (let i = 1; i < layerNodes.length; i++) {{
+                                const prev = layerNodes[i - 1];
+                                const curr = layerNodes[i];
+
+                                // 根据两个节点的文字长度计算最小间距
+                                const prevHalfWidth = (prev.nameLen * charWidth) / 2 + 10;
+                                const currHalfWidth = (curr.nameLen * charWidth) / 2 + 10;
+                                const minGap = prevHalfWidth + currHalfWidth + baseGap;
+
+                                const gap = curr.x - prev.x;
+
+                                if (gap < minGap) {{
+                                    hasOverlap = true;
+                                    // 需要分散:向两侧推开
+                                    const shift = (minGap - gap) / 2;
+                                    prev.x -= shift;
+                                    curr.x += shift;
+
+                                    // 更新原始位置
+                                    positions[prev.id].x = prev.x;
+                                    positions[curr.id].x = curr.x;
+                                }}
+                            }}
+                            if (!hasOverlap) break;
+                        }}
+                    }});
+                }}
+
+                // 对人设节点应用防重叠
+                spreadOverlappingNodes(personaNodePositions, 30, 9);
+
                 // 合并所有节点位置(标签 + 人设)
-                const allGraphNodePositions = {{ ...tagNodePositions }};
+                const allGraphNodePositions = {{}};
+                // 标签位置:取第一个位置用于合并(边绘制时会用所有位置)
+                Object.keys(tagNodePositions).forEach(id => {{
+                    allGraphNodePositions[id] = tagNodePositions[id][0];
+                }});
                 Object.keys(personaNodePositions).forEach(id => {{
                     allGraphNodePositions[id] = personaNodePositions[id];
                 }});
@@ -2318,6 +2466,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 }};
 
                 // 展开边数据:如果标签有多个位置,为每个位置创建一条边
+                // 边直接引用位置对象,绘制时动态获取 x, y
                 const expandedLinks = [];
                 crossLayerLinks.forEach(link => {{
                     const srcId = link.source.id;
@@ -2327,13 +2476,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
                     // 源是层0(帖子标签),可能有多个位置
                     if (srcLayer === 0 && tagNodePositions[srcId]) {{
-                        tagNodePositions[srcId].forEach((srcPos, idx) => {{
-                            const tgtPos = personaNodePositions[tgtId];
-                            if (tgtPos) {{
+                        tagNodePositions[srcId].forEach((srcNode, idx) => {{
+                            const tgtNode = personaNodePositions[tgtId];
+                            if (tgtNode) {{
                                 expandedLinks.push({{
                                     ...link,
-                                    srcPos: srcPos,
-                                    tgtPos: tgtPos,
+                                    srcNode: srcNode,  // 直接引用位置对象
+                                    tgtNode: tgtNode,
                                     key: `${{srcId}}_${{idx}}_${{tgtId}}`
                                 }});
                             }}
@@ -2341,13 +2490,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     }}
                     // 目标是层0(帖子标签),可能有多个位置
                     else if (tgtLayer === 0 && tagNodePositions[tgtId]) {{
-                        tagNodePositions[tgtId].forEach((tgtPos, idx) => {{
-                            const srcPos = personaNodePositions[srcId];
-                            if (srcPos) {{
+                        tagNodePositions[tgtId].forEach((tgtNode, idx) => {{
+                            const srcNode = personaNodePositions[srcId];
+                            if (srcNode) {{
                                 expandedLinks.push({{
                                     ...link,
-                                    srcPos: srcPos,
-                                    tgtPos: tgtPos,
+                                    srcNode: srcNode,
+                                    tgtNode: tgtNode,
                                     key: `${{srcId}}_${{tgtId}}_${{idx}}`
                                 }});
                             }}
@@ -2355,13 +2504,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     }}
                     // 两端都不是层0,正常处理
                     else {{
-                        const srcPos = personaNodePositions[srcId];
-                        const tgtPos = personaNodePositions[tgtId];
-                        if (srcPos && tgtPos) {{
+                        const srcNode = personaNodePositions[srcId];
+                        const tgtNode = personaNodePositions[tgtId];
+                        if (srcNode && tgtNode) {{
                             expandedLinks.push({{
                                 ...link,
-                                srcPos: srcPos,
-                                tgtPos: tgtPos,
+                                srcNode: srcNode,
+                                tgtNode: tgtNode,
                                 key: `${{srcId}}_${{tgtId}}`
                             }});
                         }}
@@ -2371,9 +2520,38 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 console.log("展开后边数:", expandedLinks.length);
 
                 // 绘制层间边(标签→人设、人设→人设扩展)
+                // 去重:同一对节点之间相同类型的边只保留一条
+                const seenEdges = new Set();
+                const dedupedLinks = expandedLinks.filter(link => {{
+                    const srcId = link.source.id || link.source;
+                    const tgtId = link.target.id || link.target;
+                    const edgeKey = [srcId, tgtId].sort().join("|") + "|" + link.type;
+                    if (seenEdges.has(edgeKey)) return false;
+                    seenEdges.add(edgeKey);
+                    return true;
+                }});
+
+                // 统计每对节点之间的边数,为多边分配不同曲线偏移
+                const edgePairCount = {{}};
+                const edgePairIndex = {{}};
+                dedupedLinks.forEach(link => {{
+                    const srcId = link.source.id || link.source;
+                    const tgtId = link.target.id || link.target;
+                    const pairKey = [srcId, tgtId].sort().join("|");
+                    edgePairCount[pairKey] = (edgePairCount[pairKey] || 0) + 1;
+                }});
+                dedupedLinks.forEach(link => {{
+                    const srcId = link.source.id || link.source;
+                    const tgtId = link.target.id || link.target;
+                    const pairKey = [srcId, tgtId].sort().join("|");
+                    if (!edgePairIndex[pairKey]) edgePairIndex[pairKey] = 0;
+                    link._pairCount = edgePairCount[pairKey];
+                    link._pairIndex = edgePairIndex[pairKey]++;
+                }});
+
                 const graphLinksGroup = g.append("g").attr("class", "graph-links");
                 const graphLinks = graphLinksGroup.selectAll(".graph-link")
-                    .data(expandedLinks)
+                    .data(dedupedLinks)
                     .enter()
                     .append("g")
                     .attr("class", "graph-link-group");
@@ -2381,10 +2559,24 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 graphLinks.append("path")
                     .attr("class", "graph-link")
                     .attr("d", d => {{
-                        const srcPos = d.srcPos;
-                        const tgtPos = d.tgtPos;
-                        if (!srcPos || !tgtPos) return "";
-                        return `M${{srcPos.x}},${{srcPos.y}} C${{srcPos.x}},${{(srcPos.y + tgtPos.y) / 2}} ${{tgtPos.x}},${{(srcPos.y + tgtPos.y) / 2}} ${{tgtPos.x}},${{tgtPos.y}}`;
+                        const src = d.srcNode;
+                        const tgt = d.tgtNode;
+                        if (!src || !tgt) return "";
+
+                        // 如果只有一条边,用默认曲线
+                        if (d._pairCount <= 1) {{
+                            return `M${{src.x}},${{src.y}} C${{src.x}},${{(src.y + tgt.y) / 2}} ${{tgt.x}},${{(src.y + tgt.y) / 2}} ${{tgt.x}},${{tgt.y}}`;
+                        }}
+
+                        // 多条边时,根据索引计算水平偏移
+                        const offsetStep = 25;
+                        const totalOffset = (d._pairCount - 1) * offsetStep;
+                        const offset = d._pairIndex * offsetStep - totalOffset / 2;
+
+                        const midY = (src.y + tgt.y) / 2;
+                        const midX = (src.x + tgt.x) / 2 + offset;
+
+                        return `M${{src.x}},${{src.y}} Q${{midX}},${{midY}} ${{tgt.x}},${{tgt.y}}`;
                     }})
                     .attr("fill", "none")
                     .attr("stroke", d => graphEdgeColors[d.type] || "#9b59b6")
@@ -2395,8 +2587,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 // 在匹配边上显示相似度
                 graphLinks.filter(d => d.type && d.type.startsWith("匹配_") && d.similarity > 0)
                     .append("text")
-                    .attr("x", d => (d.srcPos.x + d.tgtPos.x) / 2)
-                    .attr("y", d => (d.srcPos.y + d.tgtPos.y) / 2)
+                    .attr("x", d => (d.srcNode.x + d.tgtNode.x) / 2)
+                    .attr("y", d => (d.srcNode.y + d.tgtNode.y) / 2)
                     .attr("dy", "0.35em")
                     .attr("text-anchor", "middle")
                     .attr("fill", "#fff")
@@ -2765,10 +2957,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return getNodeLayer(sourceNode) !== getNodeLayer(targetNode);
             }};
 
-            // 设置跨层边的初始可见性(匹配边始终显示,其他跨层边默认隐藏)
+            // 设置跨层边的初始可见性(默认全部隐藏)
             linkG.each(function(d) {{
                 if (d.type.startsWith("匹配_")) {{
-                    d3.select(this).style("display", "block");  // 匹配边始终显示
+                    d3.select(this).style("display", "none");  // 匹配边默认隐藏
                 }} else if (isCrossLayerEdge(d) && !showCrossLayerEdges) {{
                     d3.select(this).style("display", "none");
                 }}
@@ -3692,19 +3884,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             }}
         }}
 
-        // 重置所有层级到默认配置(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 => {{
+            // 所有层级边类型默认不选
+            [1, 2, 3].forEach(level => {{
                 document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(cb => {{
-                    cb.checked = (cb.dataset.type === "包含");
+                    cb.checked = false;
                 }});
                 document.querySelector(`.select-all input[data-level="${{level}}"]`).checked = false;
             }});