소스 검색

feat: 关系图展示完整路径并支持点击交互

- 点击镜像边/二阶边时展示路径上所有人设节点和边
- 关系图中节点可点击展开关系图
- 关系图中边可点击高亮对应节点和边

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 5 일 전
부모
커밋
c47cfcbbd9
1개의 변경된 파일198개의 추가작업 그리고 47개의 파일을 삭제
  1. 198 47
      script/data_processing/visualize_match_graph.py

+ 198 - 47
script/data_processing/visualize_match_graph.py

@@ -1996,28 +1996,31 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 highlightEdge(d, linkIndex);
                 showEdgeInfo(d);
 
-                // 2. 确定要在人设树和关系图中展示的人设
+                // 2. 确定要在人设树和关系图中展示的人设节点路径
                 const edgeDetail = d.边详情 || {{}};
                 const isMirrorEdge = d.type.startsWith("镜像_") || d.type.startsWith("二阶_");
 
-                let personaSrcId, personaTgtId, edgeType;
+                let personaPathNodes = [];
+                let edgeType;
 
-                if (isMirrorEdge && edgeDetail.源人设节点 && edgeDetail.目标人设节点) {{
-                    // 镜像边:使用对应的人设节点
-                    personaSrcId = edgeDetail.源人设节点;
-                    personaTgtId = edgeDetail.目标人设节点;
+                if (isMirrorEdge && edgeDetail.路径节点) {{
+                    // 镜像边/二阶边:从路径节点中提取人设节点(不以帖子_开头的)
+                    personaPathNodes = edgeDetail.路径节点.filter(id => !id.startsWith("帖子_"));
                     edgeType = edgeDetail.原始边类型 || d.type.replace("镜像_", "").replace("二阶_", "");
                 }} else {{
                     // 普通边:使用边的两端节点
                     const sourceNode = typeof d.source === "object" ? d.source : nodes.find(n => n.id === d.source);
                     const targetNode = typeof d.target === "object" ? d.target : nodes.find(n => n.id === d.target);
-                    personaSrcId = sourceNode ? sourceNode.id : d.source;
-                    personaTgtId = targetNode ? targetNode.id : d.target;
+                    const srcId = sourceNode ? sourceNode.id : d.source;
+                    const tgtId = targetNode ? targetNode.id : d.target;
+                    // 只有人设节点才加入路径
+                    if (!srcId.startsWith("帖子_")) personaPathNodes.push(srcId);
+                    if (!tgtId.startsWith("帖子_")) personaPathNodes.push(tgtId);
                     edgeType = d.type;
                 }}
 
                 // 3. 同步人设树高亮和关系图展示
-                syncTreeAndRelationGraph(personaSrcId, personaTgtId, edgeType);
+                syncTreeAndRelationGraph(personaPathNodes, edgeType);
             }})
             .on("mouseover", function(event, d) {{
                 d3.select(this.parentNode).select(".link")
@@ -2776,7 +2779,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         }}
 
         // 同步人设树高亮和关系图展示(职责单一:不处理力导向图)
-        function syncTreeAndRelationGraph(srcNodeId, tgtNodeId, edgeType) {{
+        function syncTreeAndRelationGraph(pathNodeIds, edgeType) {{
+            if (!pathNodeIds || pathNodeIds.length === 0) return;
+
             // 动态获取树相关元素
             const treeGroup = d3.select(".persona-tree");
             if (treeGroup.empty()) return;
@@ -2804,64 +2809,51 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
             }});
 
-            const sourceTreeNode = treeNodeById[srcNodeId];
-            const targetTreeNode = treeNodeById[tgtNodeId];
+            // 路径节点ID集合
+            const pathNodeIdSet = new Set(pathNodeIds);
 
-            // 高亮树边(不变粗
+            // 高亮树边(路径上相邻节点之间的边
             treeEdges.attr("stroke-opacity", function(e) {{
-                const isThisEdge = (e.源节点ID === srcNodeId && e.目标节点ID === tgtNodeId) ||
-                                   (e.源节点ID === tgtNodeId && e.目标节点ID === srcNodeId);
-                return isThisEdge ? 1 : 0.1;
+                const srcInPath = pathNodeIdSet.has(e.源节点ID);
+                const tgtInPath = pathNodeIdSet.has(e.目标节点ID);
+                return (srcInPath && tgtInPath) ? 1 : 0.1;
             }});
 
-            // 高亮相关节点(使用节点ID集合判断)
-            const connectedNodeIds = new Set([srcNodeId, tgtNodeId]);
-
+            // 高亮路径上的节点
             treeGroup.selectAll(".tree-node").each(function(n) {{
                 const nodeId = n.data.节点ID;
-                const isConnected = connectedNodeIds.has(nodeId);
+                const isInPath = pathNodeIdSet.has(nodeId);
                 d3.select(this).select(".tree-shape")
-                    .attr("fill", isConnected ? getTreeNodeColor(n) : "#555")
+                    .attr("fill", isInPath ? getTreeNodeColor(n) : "#555")
                     .attr("opacity", 1);
                 d3.select(this).select("text")
-                    .attr("fill", isConnected ?
+                    .attr("fill", isInPath ?
                         ((n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb") : "#555")
                     .attr("opacity", 1);
             }});
 
-            // 显示详情
+            // 显示路径详情
             const panel = document.getElementById("detailPanel");
             panel.classList.add("active");
-            document.getElementById("detailTitle").textContent = "🔗 边详情";
-
-            // 从 personaTreeData 中获取节点数据(即使不在可视化树中)
-            const srcNodeData = personaTreeData.nodes.find(n => n.节点ID === srcNodeId);
-            const tgtNodeData = personaTreeData.nodes.find(n => n.节点ID === tgtNodeId);
-
-            // 构造显示用的节点名称
-            const srcName = sourceTreeNode ? (sourceTreeNode.data.节点名称 || sourceTreeNode.data.name) :
-                            (srcNodeData?.节点名称 || srcNodeId);
-            const tgtName = targetTreeNode ? (targetTreeNode.data.节点名称 || targetTreeNode.data.name) :
-                            (tgtNodeData?.节点名称 || tgtNodeId);
+            document.getElementById("detailTitle").textContent = "🔗 路径详情";
+
+            // 获取路径上的节点名称
+            const pathNodeNames = pathNodeIds.map(id => {{
+                const treeNode = treeNodeById[id];
+                if (treeNode) return treeNode.data.节点名称 || treeNode.data.name;
+                const nodeData = personaTreeData.nodes.find(n => n.节点ID === id);
+                return nodeData?.节点名称 || id;
+            }});
 
             let html = `
                 <p><span class="label">边类型:</span> <strong>${{edgeType}}</strong></p>
-                <p><span class="label">源节点:</span> ${{srcName}}</p>
-                <p><span class="label">目标节点:</span> ${{tgtName}}</p>
+                <p><span class="label">路径节点:</span> ${{pathNodeNames.join(" → ")}}</p>
+                <p><span class="label">节点数:</span> ${{pathNodeIds.length}}</p>
             `;
             document.getElementById("detailContent").innerHTML = html;
 
-            // 在关系图中展示这条边和两个节点
-            // 构造节点对象(优先使用树节点,否则使用 personaTreeData 中的数据)
-            const srcForEgo = sourceTreeNode || (srcNodeData ? {{ data: srcNodeData }} : null);
-            const tgtForEgo = targetTreeNode || (tgtNodeData ? {{ data: tgtNodeData }} : null);
-
-            const edgeData = {{
-                边类型: edgeType,
-                源节点ID: srcNodeId,
-                目标节点ID: tgtNodeId
-            }};
-            renderEgoGraphEdge(edgeData, srcForEgo, tgtForEgo);
+            // 在关系图中展示路径上的所有节点和边
+            renderEgoGraphPath(pathNodeIds, edgeType);
         }}
 
         // 关系子图(Ego Graph)- 在画布第四层显示
@@ -3434,6 +3426,165 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .style("visibility", "visible");
         }}
 
+        // 渲染路径上的所有节点和边(点击镜像边/二阶边时调用)
+        function renderEgoGraphPath(pathNodeIds, edgeType) {{
+            if (!pathNodeIds || pathNodeIds.length === 0) return;
+
+            // 显示关系图容器
+            document.getElementById("ego-container").style.display = "block";
+
+            const egoGroup = d3.select(".ego-graph-content");
+            if (egoGroup.empty()) return;
+
+            // 停止之前的模拟
+            if (currentEgoSimulation) {{
+                currentEgoSimulation.stop();
+                currentEgoSimulation = null;
+            }}
+
+            // 清除旧内容
+            egoGroup.selectAll("*").remove();
+
+            // 边类型颜色
+            const edgeColors = {{
+                "属于": "#9b59b6",
+                "包含": "#ffb6c1",
+                "分类共现(跨点)": "#2ecc71",
+                "分类共现(点内)": "#3498db",
+                "标签共现": "#f39c12"
+            }};
+
+            // 维度颜色
+            const dimColors = {{
+                "灵感点": "#f39c12",
+                "目的点": "#3498db",
+                "关键点": "#9b59b6"
+            }};
+
+            // 获取节点颜色
+            function getNodeColor(nodeData) {{
+                const level = nodeData.节点层级 || "";
+                if (level.includes("灵感点")) return dimColors["灵感点"];
+                if (level.includes("目的点")) return dimColors["目的点"];
+                if (level.includes("关键点")) return dimColors["关键点"];
+                return "#888";
+            }}
+
+            // 获取路径节点数据
+            const pathNodes = pathNodeIds.map(id => {{
+                const nodeData = personaTreeData.nodes.find(n => n.节点ID === id);
+                return nodeData || {{ 节点ID: id, 节点名称: id, 节点类型: "标签" }};
+            }});
+
+            // 找出路径上相邻节点之间的边
+            const pathEdges = [];
+            for (let i = 0; i < pathNodeIds.length - 1; i++) {{
+                const srcId = pathNodeIds[i];
+                const tgtId = pathNodeIds[i + 1];
+                // 在 personaTreeData.edges 中查找
+                const edge = personaTreeData.edges.find(e =>
+                    (e.源节点ID === srcId && e.目标节点ID === tgtId) ||
+                    (e.源节点ID === tgtId && e.目标节点ID === srcId)
+                );
+                pathEdges.push(edge || {{ 边类型: edgeType, 源节点ID: srcId, 目标节点ID: tgtId }});
+            }}
+
+            // 更新顶部标题
+            document.getElementById("ego-node-name").textContent = edgeType;
+            document.getElementById("ego-stats").textContent = `${{pathNodes.length}}节点 ${{pathEdges.length}}边`;
+
+            // 水平线性布局
+            const nodeSpacing = 80;
+            const totalWidth = (pathNodes.length - 1) * nodeSpacing;
+            const startX = -totalWidth / 2;
+
+            // 绘制边
+            pathEdges.forEach((edge, i) => {{
+                const x1 = startX + i * nodeSpacing;
+                const x2 = startX + (i + 1) * nodeSpacing;
+
+                // 边的可点击区域(更宽的透明线)
+                egoGroup.append("line")
+                    .attr("class", "ego-edge-hitarea")
+                    .attr("x1", x1)
+                    .attr("y1", 0)
+                    .attr("x2", x2)
+                    .attr("y2", 0)
+                    .attr("stroke", "transparent")
+                    .attr("stroke-width", 15)
+                    .style("cursor", "pointer")
+                    .on("click", (event) => {{
+                        event.stopPropagation();
+                        handleEdgeClick(edge.源节点ID, edge.目标节点ID, edge.边类型);
+                    }});
+
+                // 可见的边
+                egoGroup.append("line")
+                    .attr("class", "ego-edge")
+                    .attr("x1", x1)
+                    .attr("y1", 0)
+                    .attr("x2", x2)
+                    .attr("y2", 0)
+                    .attr("stroke", edgeColors[edge.边类型] || "#666")
+                    .attr("stroke-width", 3)
+                    .attr("stroke-opacity", 0.8)
+                    .style("pointer-events", "none");
+
+                // 边类型标签
+                egoGroup.append("text")
+                    .attr("x", (x1 + x2) / 2)
+                    .attr("y", 20)
+                    .attr("text-anchor", "middle")
+                    .attr("fill", "#999")
+                    .attr("font-size", "9px")
+                    .style("pointer-events", "none")
+                    .text(edge.边类型);
+            }});
+
+            // 绘制节点
+            pathNodes.forEach((nodeData, i) => {{
+                const x = startX + i * nodeSpacing;
+                const nodeGroup = egoGroup.append("g")
+                    .attr("class", "ego-node")
+                    .attr("transform", `translate(${{x}}, 0)`)
+                    .style("cursor", "pointer")
+                    .on("click", (event) => {{
+                        event.stopPropagation();
+                        handleNodeClick(nodeData.节点ID, nodeData.节点名称 || nodeData.name);
+                    }});
+
+                const nodeSize = 15;
+                const nodeColor = getNodeColor(nodeData);
+
+                if (nodeData.节点类型 === "分类") {{
+                    nodeGroup.append("rect")
+                        .attr("width", nodeSize * 2)
+                        .attr("height", nodeSize * 2)
+                        .attr("x", -nodeSize)
+                        .attr("y", -nodeSize)
+                        .attr("fill", nodeColor)
+                        .attr("stroke", "rgba(255,255,255,0.5)")
+                        .attr("stroke-width", 2)
+                        .attr("rx", 2);
+                }} else {{
+                    nodeGroup.append("circle")
+                        .attr("r", nodeSize)
+                        .attr("fill", nodeColor)
+                        .attr("stroke", "rgba(255,255,255,0.5)")
+                        .attr("stroke-width", 2);
+                }}
+
+                // 节点名称
+                nodeGroup.append("text")
+                    .attr("dy", -nodeSize - 8)
+                    .attr("text-anchor", "middle")
+                    .attr("fill", "#fff")
+                    .attr("font-size", "10px")
+                    .style("pointer-events", "none")
+                    .text(nodeData.节点名称 || nodeData.name || nodeData.节点ID);
+            }});
+        }}
+
         // 渲染单条边和两个节点(点击树边时调用)
         function renderEgoGraphEdge(edgeData, sourceNode, targetNode) {{
             // 显示关系图容器