Browse Source

feat: 添加左右视图联动高亮效果

- 右边树节点点击复用handleNodeClick,与左边圆行为一致
- 相关图1级边类型默认全选
- 点击左边圆节点/边时,右边树对应的节点和边同步高亮
- 点击帖子标签节点也能联动右边树
- 点击空白处恢复右边树样式
- 修复graph-node和graph-link-group选择器问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 4 ngày trước cách đây
mục cha
commit
b7ee3bf609
1 tập tin đã thay đổi với 126 bổ sung20 xóa
  1. 126 20
      script/data_processing/visualize_match_graph.py

+ 126 - 20
script/data_processing/visualize_match_graph.py

@@ -958,12 +958,12 @@ 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>全选</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 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>
@@ -2143,7 +2143,13 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .enter()
                     .append("g")
                     .attr("class", "tree-node")
-                    .attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+                    .attr("transform", d => `translate(${{d.x}},${{d.y}})`)
+                    .style("cursor", "pointer")
+                    .on("click", (event, d) => {{
+                        event.stopPropagation();
+                        // 复用共享的节点点击处理函数
+                        handleNodeClick(d.data.id || d.data.节点ID, d.data.name || d.data.节点名称);
+                    }});
 
                 // 节点形状
                 treeNodes.each(function(d) {{
@@ -2644,11 +2650,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .attr("font-size", "11px")
                     .text(d => d.节点名称 || d.name || "");
 
-                // 点击联动
+                // 点击联动 - 复用共享的节点点击处理函数
                 graphNodes.on("click", function(event, d) {{
                     event.stopPropagation();
-                    nodeElements.classed("highlighted", n => n.id === d.id);
-                    nodeElements.classed("dimmed", n => n.id !== d.id);
+                    handleNodeClick(d.id || d.节点ID, d.节点名称 || d.name);
                 }});
 
                 // 存储引用
@@ -2926,6 +2931,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 if (event.target === this) {{
                     resetTreeHighlight();
                     resetGraphHighlight();
+                    resetRightTreeHighlight();
                     clearEgoGraph();
                     closeDetailPanel();
                 }}
@@ -3017,6 +3023,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
                 // 3. 同步人设树高亮和关系图展示
                 syncTreeAndRelationGraph(personaPathNodes, edgeType);
+                // 注:highlightEdge 内部已调用 highlightRightTree 联动右边树
             }})
             .on("mouseover", function(event, d) {{
                 d3.select(this.parentNode).select(".link")
@@ -3123,9 +3130,25 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 event.stopPropagation();
                 highlightNode(d);
                 showNodeInfo(d);
-                // 人设节点联动:高亮人设树 + 显示关系图
+                // 联动:高亮人设树 + 显示关系图 + 右边树
                 if (d.source === "人设") {{
                     handleNodeClick(d.节点ID, d.节点名称);
+                }} else {{
+                    // 帖子标签节点:直接联动右边树高亮
+                    const highlightedNodeIds = new Set([d.id]);
+                    const highlightedEdgeKeys = new Set();
+                    // 找相连的边和节点
+                    g.selectAll(".link-group").each(function(link) {{
+                        const srcId = typeof link.source === "object" ? link.source.id : link.source;
+                        const tgtId = typeof link.target === "object" ? link.target.id : link.target;
+                        if (srcId === d.id || tgtId === d.id) {{
+                            highlightedNodeIds.add(srcId);
+                            highlightedNodeIds.add(tgtId);
+                            highlightedEdgeKeys.add(`${{srcId}}|${{tgtId}}`);
+                            highlightedEdgeKeys.add(`${{tgtId}}|${{srcId}}`);
+                        }}
+                    }});
+                    highlightRightTree(highlightedNodeIds, highlightedEdgeKeys);
                 }}
             }});
 
@@ -3334,6 +3357,17 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         d3.select(this).style("display", "block");
                     }}
                 }});
+
+                // 联动右边树:收集高亮边的key
+                const highlightEdgeKeys = new Set();
+                highlightLinkIndices.forEach(i => {{
+                    const link = links[i];
+                    const srcId = typeof link.source === "object" ? link.source.id : link.source;
+                    const tgtId = typeof link.target === "object" ? link.target.id : link.target;
+                    highlightEdgeKeys.add(`${{srcId}}|${{tgtId}}`);
+                    highlightEdgeKeys.add(`${{tgtId}}|${{srcId}}`);
+                }});
+                highlightRightTree(highlightNodeIds, highlightEdgeKeys);
             }}
 
             // 点击空白处清除高亮(合并所有空白点击逻辑)
@@ -3348,6 +3382,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     // 清除人设树高亮
                     resetTreeHighlight();
                     resetGraphHighlight();
+                    // 恢复右边帖子树
+                    resetRightTreeHighlight();
                     // 关闭详情面板
                     closeDetailPanel();
                     // 清除关系图
@@ -3731,28 +3767,32 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 }}, 50);
             }}
 
-            // 高亮右侧图中对应节点及其相关节点和边
+            // 高亮左边圆中对应节点及边,同时收集用于右边树联动的数据
+            // 收集高亮的节点ID和边key(左边圆高亮什么,右边树就高亮什么)
+            const highlightedNodeIds = new Set([clickedNodeId]);
+            const highlightedEdgeKeys = new Set();
+
             if (g) {{
-                // 收集相关节点ID(点击的节点及其直接连接的节点)
-                const relatedNodeIds = new Set([clickedNodeId]);
-                // 找所有与该节点相连的边
+                // 找所有与该节点相连的边,收集相关节点和边
                 g.selectAll(".link-group").each(function(d) {{
                     const srcId = typeof d.source === "object" ? d.source.id : d.source;
                     const tgtId = typeof d.target === "object" ? d.target.id : d.target;
                     if (srcId === clickedNodeId || tgtId === clickedNodeId) {{
-                        relatedNodeIds.add(srcId);
-                        relatedNodeIds.add(tgtId);
+                        highlightedNodeIds.add(srcId);
+                        highlightedNodeIds.add(tgtId);
+                        highlightedEdgeKeys.add(`${{srcId}}|${{tgtId}}`);
+                        highlightedEdgeKeys.add(`${{tgtId}}|${{srcId}}`);
                     }}
                 }});
 
                 // 检查是否有相关节点在图中
-                const hasRelatedNodes = g.selectAll(".node").filter(n => relatedNodeIds.has(n.id)).size() > 0;
+                const hasRelatedNodes = g.selectAll(".node").filter(n => highlightedNodeIds.has(n.id)).size() > 0;
 
                 if (hasRelatedNodes) {{
                     // 高亮相关节点,变灰其他
                     g.selectAll(".node")
-                        .classed("dimmed", n => !relatedNodeIds.has(n.id))
-                        .classed("highlighted", n => relatedNodeIds.has(n.id));
+                        .classed("dimmed", n => !highlightedNodeIds.has(n.id))
+                        .classed("highlighted", n => highlightedNodeIds.has(n.id));
                     // 高亮相关边
                     g.selectAll(".link-group").each(function(d) {{
                         const srcId = typeof d.source === "object" ? d.source.id : d.source;
@@ -3772,6 +3812,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     g.selectAll(".link-group").classed("dimmed", true).classed("highlighted", false);
                 }}
             }}
+
+            // 联动高亮右边帖子树(直接用左边圆高亮的节点和边)
+            highlightRightTree(highlightedNodeIds, highlightedEdgeKeys);
         }}
 
         // 同步人设树高亮和关系图展示(职责单一:不处理力导向图)
@@ -4400,6 +4443,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .style("visibility", function(d) {{
                     return pathNodes.has(d.id) ? "visible" : "hidden";
                 }});
+
+            // 联动:高亮右边树上对应的节点和边
+            highlightRightTree(pathNodes, pathEdgeKeys);
         }}
 
         // 恢复关系图的高亮状态
@@ -4414,6 +4460,66 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 恢复节点的可见性
             egoGroup.selectAll(".ego-node")
                 .style("visibility", "visible");
+
+            // 联动:恢复右边树
+            resetRightTreeHighlight();
+        }}
+
+        // 高亮右边树上对应的节点和边
+        function highlightRightTree(pathNodes, pathEdgeKeys) {{
+            const postTreeGroup = d3.select(".post-tree");
+
+            // 高亮帖子树节点
+            if (!postTreeGroup.empty()) {{
+                postTreeGroup.selectAll(".tree-node")
+                    .style("opacity", function(d) {{
+                        const nodeId = d.data.id || d.data.节点ID;
+                        return pathNodes.has(nodeId) ? 1 : 0.2;
+                    }});
+
+                // 高亮帖子树边
+                postTreeGroup.selectAll(".tree-link")
+                    .style("opacity", function(d) {{
+                        const srcId = d.source.data.id || d.source.data.节点ID;
+                        const tgtId = d.target.data.id || d.target.data.节点ID;
+                        // 如果两端节点都在路径中,边高亮
+                        return (pathNodes.has(srcId) && pathNodes.has(tgtId)) ? 1 : 0.2;
+                    }});
+            }}
+
+            // 高亮人设节点(右边下方的图节点,在g下面不是postTreeGroup下)
+            d3.selectAll(".graph-node")
+                .style("opacity", function(d) {{
+                    return pathNodes.has(d.id) ? 1 : 0.2;
+                }});
+
+            // 高亮跨层边(graph-link-group 包含 graph-link)
+            d3.selectAll(".graph-link-group")
+                .style("opacity", function(d) {{
+                    const srcId = d.source ? (d.source.id || d.source) : null;
+                    const tgtId = d.target ? (d.target.id || d.target) : null;
+                    if (!srcId || !tgtId) return 0.2;
+                    const key1 = `${{srcId}}|${{tgtId}}`;
+                    const key2 = `${{tgtId}}|${{srcId}}`;
+                    return (pathEdgeKeys.has(key1) || pathEdgeKeys.has(key2)) ? 1 : 0.2;
+                }});
+        }}
+
+        // 恢复右边树的高亮状态
+        function resetRightTreeHighlight() {{
+            const postTreeGroup = d3.select(".post-tree");
+
+            // 恢复帖子树节点和边
+            if (!postTreeGroup.empty()) {{
+                postTreeGroup.selectAll(".tree-node").style("opacity", 1);
+                postTreeGroup.selectAll(".tree-link").style("opacity", 1);
+            }}
+
+            // 恢复人设节点(在g下面)
+            d3.selectAll(".graph-node").style("opacity", 1);
+
+            // 恢复跨层边
+            d3.selectAll(".graph-link-group").style("opacity", 1);
         }}
 
         // 渲染路径上的所有节点和边(点击镜像边/二阶边时调用)