Переглянути джерело

feat: 完善匹配图构建和可视化功能

- 包含所有帖子特征节点(即使没有匹配到人设节点)
- 添加"已匹配"标记区分匹配/未匹配状态
- 未匹配节点使用较低透明度和灰色显示
- 统一镜像边和二阶边样式(使用相同颜色和虚线)
- 增加帖子节点间的布局间距(排斥力、链接距离、碰撞半径)
- 优化力导向模拟参数减少节点重叠

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 6 днів тому
батько
коміт
a38e8f0465

+ 17 - 17
script/data_processing/build_match_graph.py

@@ -75,24 +75,24 @@ def extract_matched_nodes_and_edges(filtered_data: Dict) -> tuple:
                     if not feature_name:
                         continue
 
-                    # 如果有匹配结果,创建帖子节点和匹配边
+                    # 创建帖子节点(无论是否有匹配结果)
+                    post_node_id = build_post_node_id(dimension, "标签", feature_name)
+                    post_node = {
+                        "节点ID": post_node_id,
+                        "节点名称": feature_name,
+                        "节点类型": "标签",
+                        "节点层级": dimension,
+                        "权重": weight,
+                        "source": "帖子",
+                        "已匹配": len(match_results) > 0  # 标记是否有匹配
+                    }
+
+                    # 避免重复添加
+                    if not any(n["节点ID"] == post_node_id for n in post_nodes):
+                        post_nodes.append(post_node)
+
+                    # 如果有匹配结果,创建匹配边
                     if match_results:
-                        # 创建帖子节点(标签类型)
-                        post_node_id = build_post_node_id(dimension, "标签", feature_name)
-                        post_node = {
-                            "节点ID": post_node_id,
-                            "节点名称": feature_name,
-                            "节点类型": "标签",
-                            "节点层级": dimension,
-                            "权重": weight,
-                            "source": "帖子"
-                        }
-
-                        # 避免重复添加
-                        if not any(n["节点ID"] == post_node_id for n in post_nodes):
-                            post_nodes.append(post_node)
-
-                        # 处理每个匹配结果
                         for match in match_results:
                             persona_name = match.get("人设特征名称", "")
                             persona_dimension = match.get("人设特征层级", "")

+ 38 - 14
script/data_processing/visualize_match_graph.py

@@ -193,6 +193,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             stroke: #fff;
             stroke-dasharray: 4,2;
         }}
+        .node .post-node.unmatched {{
+            stroke: #555;
+            stroke-dasharray: 2,2;
+        }}
         .node .persona-node {{
             stroke: #fff;
         }}
@@ -265,11 +269,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             stroke: #8e44ad;
             stroke-dasharray: 6,3;
         }}
-        /* 二阶镜像边样式(点划线) */
-        .link.second-order {{
-            stroke: #17a2b8;
-            stroke-dasharray: 8,3,2,3;
-        }}
+        /* 二阶边现在使用与镜像边相同的样式(基于原始边类型) */
         /* 高亮/灰化样式 */
         .node.dimmed circle, .node.dimmed rect {{
             opacity: 0.15 !important;
@@ -760,8 +760,21 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             // 力导向模拟
             simulation = d3.forceSimulation(nodes)
-                .force("link", d3.forceLink(links).id(d => d.id).distance(80).strength(0.1))
-                .force("charge", d3.forceManyBody().strength(-300))
+                .force("link", d3.forceLink(links).id(d => d.id)
+                    .distance(d => {{
+                        // 帖子之间的边(镜像边/二阶边)距离更大
+                        if (d.type && (d.type.startsWith("镜像_") || d.type.startsWith("二阶_"))) {{
+                            return 200;
+                        }}
+                        return 80;
+                    }})
+                    .strength(0.1))
+                .force("charge", d3.forceManyBody()
+                    .strength(d => {{
+                        // 帖子节点排斥力更强
+                        if (d.source === "帖子") return -600;
+                        return -300;
+                    }}))
                 // X方向:拉向目标位置,但允许被推开
                 .force("x", d3.forceX(d => nodeTargetX[d.id] || width / 2).strength(0.15))
                 // Y方向力:三层布局+倾斜
@@ -769,7 +782,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     const baseY = getNodeBaseY(d);
                     return getTiltedY(baseY, d.x || width / 2);
                 }}).strength(0.5))
-                .force("collision", d3.forceCollide().radius(40));
+                .force("collision", d3.forceCollide()
+                    .radius(d => {{
+                        // 帖子节点碰撞半径更大
+                        if (d.source === "帖子") return 60;
+                        return 40;
+                    }}));
 
             // 边类型到CSS类的映射
             const edgeTypeClass = {{
@@ -787,10 +805,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 "镜像_包含": "mirror-contain"
             }};
 
-            // 获取边的CSS类(处理二阶边)
+            // 获取边的CSS类(处理二阶边,映射到对应的镜像边样式
             function getEdgeClass(edgeType) {{
                 if (edgeTypeClass[edgeType]) return edgeTypeClass[edgeType];
-                if (edgeType.startsWith("二阶_")) return "second-order";
+                // 二阶边映射到对应的镜像边样式(颜色相同,都用虚线)
+                if (edgeType.startsWith("二阶_")) {{
+                    const originalType = edgeType.replace("二阶_", "");
+                    const mirrorType = "镜像_" + originalType;
+                    if (edgeTypeClass[mirrorType]) return edgeTypeClass[mirrorType];
+                }}
                 return "match";
             }}
 
@@ -859,14 +882,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .on("end", dragended));
 
             // 根据节点类型绘制不同形状:标签用圆形,分类用方形
-            // 扩展节点用较低透明度表示
+            // 扩展节点和未匹配节点用较低透明度表示
             node.each(function(d) {{
                 const el = d3.select(this);
                 const isExpanded = d.是否扩展 === true;
+                const isUnmatched = d.source === "帖子" && d.已匹配 === false;
                 const size = d.source === "帖子" ? 12 : (isExpanded ? 8 : 10);
                 const fill = levelColors[d.level] || "#666";
-                const nodeClass = d.source === "帖子" ? "post-node" : "persona-node";
-                const opacity = isExpanded ? 0.5 : 1;
+                const nodeClass = d.source === "帖子" ? (isUnmatched ? "post-node unmatched" : "post-node") : "persona-node";
+                const opacity = isExpanded ? 0.5 : (isUnmatched ? 0.4 : 1);
 
                 if (d.节点类型 === "分类") {{
                     // 方形
@@ -883,7 +907,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     // 圆形(标签)
                     el.append("circle")
                         .attr("r", size)
-                        .attr("fill", fill)
+                        .attr("fill", isUnmatched ? "#666" : fill)
                         .attr("class", nodeClass)
                         .attr("opacity", opacity);
                 }}