Prechádzať zdrojové kódy

feat: 帖子树增加人设匹配两层节点,优化布局与样式统一

- build_post_tree.py: 新增人设匹配节点(1层)和扩展节点(2层),
  包含originalType字段区分分类/标签
- visualize_match_graph.py: 帖子树移至圆的右侧,Y坐标与三层圆心对齐
- 统一节点形状(分类=方形,标签=圆形)和边颜色与圆形图一致
- 权重/相似度分数居中显示在边上

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 4 dní pred
rodič
commit
ea937e383b

+ 142 - 3
script/data_processing/build_post_tree.py

@@ -64,7 +64,11 @@ def build_post_trees():
         post_tags = match_graph_data.get("帖子标签节点列表", [])
         belong_edges = match_graph_data.get("帖子属于边列表", [])
 
-        print(f"  帖子点: {len(post_points)}, 帖子标签: {len(post_tags)}, 属于边: {len(belong_edges)}")
+        # 获取匹配边(帖子标签 -> 人设标签)
+        all_edges = match_graph_data.get("边列表", [])
+        match_edges = [e for e in all_edges if e["边类型"].startswith("匹配_")]
+
+        print(f"  帖子点: {len(post_points)}, 帖子标签: {len(post_tags)}, 属于边: {len(belong_edges)}, 匹配边: {len(match_edges)}")
 
         # 构建树结构
         # 维度颜色
@@ -99,6 +103,136 @@ def build_post_trees():
                 "children": []
             }
 
+        # 获取所有节点(用于查找扩展节点)
+        all_nodes = match_graph_data.get("节点列表", [])
+        expanded_nodes_map = {}
+        for n in all_nodes:
+            if n.get("是否扩展"):
+                expanded_nodes_map[n["节点ID"]] = n
+
+        # 构建人设节点之间的边关系(用于找扩展节点)
+        # 边类型:属于、包含、分类共现等
+        persona_edges = [e for e in all_edges if not e["边类型"].startswith("匹配_")]
+
+        # 构建帖子标签到人设匹配的映射
+        tag_to_persona_matches = {}
+        direct_persona_ids = set()  # 记录直接匹配的人设ID
+
+        for e in match_edges:
+            src_id = e["源节点ID"]  # 帖子标签
+            tgt_id = e["目标节点ID"]  # 人设标签
+            edge_type = e["边类型"]  # 匹配_相同 或 匹配_相似
+            edge_detail = e.get("边详情", {})
+            similarity = edge_detail.get("相似度", 0)
+
+            if src_id not in tag_to_persona_matches:
+                tag_to_persona_matches[src_id] = []
+
+            direct_persona_ids.add(tgt_id)
+
+            # 从人设标签ID提取维度和名称
+            persona_name = tgt_id
+            persona_level = ""
+            if "_标签_" in tgt_id:
+                parts = tgt_id.split("_标签_")
+                persona_level = parts[0]
+                persona_name = parts[1] if len(parts) > 1 else tgt_id
+            elif "_分类_" in tgt_id:
+                parts = tgt_id.split("_分类_")
+                persona_level = parts[0]
+                persona_name = parts[1] if len(parts) > 1 else tgt_id
+
+            # 判断原始节点类型(分类/标签)
+            original_type = "标签" if "_标签_" in tgt_id else ("分类" if "_分类_" in tgt_id else "标签")
+
+            persona_node = {
+                "id": f"persona_{tgt_id}",
+                "name": persona_name,
+                "nodeType": "人设",
+                "originalType": original_type,  # 原始类型:分类或标签
+                "personaId": tgt_id,
+                "level": persona_level,
+                "dimColor": dim_colors.get(persona_level, "#2ecc71"),
+                "matchType": edge_type.replace("匹配_", ""),
+                "similarity": similarity,
+                "children": []
+            }
+            tag_to_persona_matches[src_id].append(persona_node)
+
+        # 为每个直接匹配的人设节点找扩展节点(第二层)
+        persona_to_expanded = {}
+        for e in persona_edges:
+            src_id = e["源节点ID"]
+            tgt_id = e["目标节点ID"]
+            edge_type = e["边类型"]
+
+            # 如果源是直接匹配节点,目标是扩展节点
+            if src_id in direct_persona_ids and tgt_id in expanded_nodes_map:
+                if src_id not in persona_to_expanded:
+                    persona_to_expanded[src_id] = []
+                exp_node = expanded_nodes_map[tgt_id]
+                exp_name = exp_node.get("节点名称", tgt_id)
+                exp_level = exp_node.get("节点层级", "")
+
+                # 扩展节点的原始类型
+                exp_original_type = exp_node.get("节点类型", "标签")
+
+                expanded_node = {
+                    "id": f"expanded_{tgt_id}",
+                    "name": exp_name,
+                    "nodeType": "人设扩展",
+                    "originalType": exp_original_type,  # 分类或标签
+                    "personaId": tgt_id,
+                    "level": exp_level,
+                    "dimColor": dim_colors.get(exp_level, "#2ecc71"),
+                    "edgeType": edge_type,
+                    "children": []
+                }
+                # 避免重复
+                if not any(x["personaId"] == tgt_id for x in persona_to_expanded[src_id]):
+                    persona_to_expanded[src_id].append(expanded_node)
+
+            # 如果目标是直接匹配节点,源是扩展节点
+            if tgt_id in direct_persona_ids and src_id in expanded_nodes_map:
+                if tgt_id not in persona_to_expanded:
+                    persona_to_expanded[tgt_id] = []
+                exp_node = expanded_nodes_map[src_id]
+                exp_name = exp_node.get("节点名称", src_id)
+                exp_level = exp_node.get("节点层级", "")
+                exp_original_type = exp_node.get("节点类型", "标签")
+
+                expanded_node = {
+                    "id": f"expanded_{src_id}",
+                    "name": exp_name,
+                    "nodeType": "人设扩展",
+                    "originalType": exp_original_type,
+                    "personaId": src_id,
+                    "level": exp_level,
+                    "dimColor": dim_colors.get(exp_level, "#2ecc71"),
+                    "edgeType": edge_type,
+                    "children": []
+                }
+                if not any(x["personaId"] == src_id for x in persona_to_expanded[tgt_id]):
+                    persona_to_expanded[tgt_id].append(expanded_node)
+
+        # 将扩展节点添加到对应的人设节点下
+        expanded_count = 0
+        for tag_id, persona_nodes in tag_to_persona_matches.items():
+            for persona_node in persona_nodes:
+                persona_id = persona_node["personaId"]
+                if persona_id in persona_to_expanded:
+                    persona_node["children"] = persona_to_expanded[persona_id]
+                    expanded_count += len(persona_to_expanded[persona_id])
+
+        # 将人设匹配节点添加到对应标签下
+        persona_count = 0
+        for tag_id, persona_nodes in tag_to_persona_matches.items():
+            if tag_id in tag_map:
+                tag_map[tag_id]["children"] = persona_nodes
+                persona_count += len(persona_nodes)
+
+        print(f"  人设匹配节点(1层): {persona_count}, 扩展节点(2层): {expanded_count}")
+
         # 根据属于边,把标签挂到点下面
         for e in belong_edges:
             tag_node = tag_map.get(e["源节点ID"])
@@ -144,6 +278,10 @@ def build_post_trees():
             total_nodes += len(dim_node["children"])  # 点节点
             for point_node in dim_node["children"]:
                 total_nodes += len(point_node["children"])  # 标签节点
+                for tag_node in point_node["children"]:
+                    total_nodes += len(tag_node["children"])  # 人设节点(1层)
+                    for persona_node in tag_node["children"]:
+                        total_nodes += len(persona_node["children"])  # 扩展节点(2层)
 
         post_tree = {
             "postId": post_id,
@@ -153,12 +291,13 @@ def build_post_trees():
             "stats": {
                 "totalNodes": total_nodes,
                 "pointCount": len(post_points),
-                "tagCount": len(post_tags)
+                "tagCount": len(post_tags),
+                "personaCount": persona_count
             }
         }
 
         all_post_trees.append(post_tree)
-        print(f"  构建完成: {total_nodes} 个节点")
+        print(f"  构建完成: {total_nodes} 个节点(人设1层: {persona_count}, 扩展2层: {expanded_count})")
 
     # 输出
     output_data = {

+ 184 - 34
script/data_processing/visualize_match_graph.py

@@ -2011,7 +2011,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return "match";
             }}
 
-            // ===== 在上方绘制帖子树(只是展示用)=====
+            // ===== 帖子树放在圆的右侧,与圆层对齐 =====
             const postDetail = data.postDetail || {{}};
             window.currentPostDetail = postDetail;
 
@@ -2021,12 +2021,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 帖子树D3布局(只在有帖子树数据时绘制)
             const postTreeGroup = g.append("g").attr("class", "post-tree");
             let postTreeActualHeight = 0;
-            let postTreeOffsetY = 60;
             let postRoot = null;
 
+            // 卡片尺寸(提前定义)
+            const cardWidth = 240;
+            const cardHeight = 90;
+
             if (postTree && postTree.root) {{
             const postTreeLayout = d3.tree()
-                .nodeSize([70, 80])
+                .nodeSize([50, 80])
                 .separation((a, b) => a.parent === b.parent ? 1.2 : 1.5);
 
             postRoot = d3.hierarchy(postTree.root);
@@ -2041,13 +2044,69 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 treeMaxY = Math.max(treeMaxY, d.y);
             }});
 
-            // 帖子树居中,放在圆的上方
-            const postTreeOffsetX = circleAreaCenterX - (treeMinX + treeMaxX) / 2;
-            postTreeGroup.attr("transform", `translate(${{postTreeOffsetX}}, ${{postTreeOffsetY}})`);
+            // 找出各层的Y坐标
+            let rootLayerY = 0, dimLayerY = 0, pointLayerY = 0;
+            let tagLayerY = 0, personaLayerY = 0, expandedLayerY = 0;
+            postRoot.descendants().forEach(d => {{
+                if (d.data.isRoot) rootLayerY = d.y;
+                if (d.data.isDimension) dimLayerY = d.y;
+                if (d.data.nodeType === "点") pointLayerY = d.y;
+                if (d.data.nodeType === "标签") tagLayerY = d.y;
+                if (d.data.nodeType === "人设") personaLayerY = d.y;
+                if (d.data.nodeType === "人设扩展") expandedLayerY = d.y;
+            }});
 
-            // 卡片尺寸(提前定义,边绘制时需要用)
-            const cardWidth = 240;
-            const cardHeight = 90;
+            // 计算目标圆心Y坐标(加上circleYOffset后的实际值)
+            const circleYOffsetPreview = 40;
+            const targetTagY = layerCenterY[0] + circleYOffsetPreview;      // 帖子标签圆心
+            const targetPersonaY = layerCenterY[1] + circleYOffsetPreview;  // 人设匹配_1层圆心
+            const targetExpandedY = layerCenterY[2] + circleYOffsetPreview; // 人设匹配_2层圆心
+
+            // 计算层间距
+            const circleGap = targetPersonaY - targetTagY;  // 两个圆心之间的距离
+            const hasExpandedLayer = expandedLayerY > 0;
+
+            // 重新分配Y坐标
+            // 前3层(帖子、维度、点)层高是后面层高的一半
+            // 后3层(标签、人设、人设扩展)对齐三个圆心
+            const halfGap = circleGap / 2;
+
+            postRoot.descendants().forEach(d => {{
+                if (d.data.isRoot) {{
+                    // 帖子层:标签层上方 2.5 个半层高
+                    d.y = targetTagY - halfGap * 3;
+                }} else if (d.data.isDimension) {{
+                    // 维度层:标签层上方 2 个半层高
+                    d.y = targetTagY - halfGap * 2;
+                }} else if (d.data.nodeType === "点") {{
+                    // 点层:标签层上方 1 个半层高
+                    d.y = targetTagY - halfGap;
+                }} else if (d.data.nodeType === "标签") {{
+                    // 标签层对齐帖子标签圆心
+                    d.y = targetTagY;
+                }} else if (d.data.nodeType === "人设") {{
+                    // 人设层对齐人设匹配_1层圆心
+                    d.y = targetPersonaY;
+                }} else if (d.data.nodeType === "人设扩展") {{
+                    // 扩展层对齐人设匹配_2层圆心
+                    d.y = targetExpandedY;
+                }}
+            }});
+
+            // 更新边界(因为Y坐标变了)
+            treeMinY = Infinity; treeMaxY = -Infinity;
+            postRoot.descendants().forEach(d => {{
+                treeMinY = Math.min(treeMinY, d.y);
+                treeMaxY = Math.max(treeMaxY, d.y);
+            }});
+
+            // 树放在圆的右侧,确保树的最左边与圆的最右边有间隔
+            const circleRightEdge = circleAreaCenterX + unifiedRadius;
+            const treeGap = 120;  // 圆右边与树左边的间隔
+            const postTreeOffsetX = circleRightEdge + treeGap - treeMinX;
+
+            // Y方向不需要额外偏移,因为已经直接设置了目标Y坐标
+            const postTreeOffsetY = 0;
 
             // 绘制帖子树边(属于边,紫色,粗细根据权重)
             const postTreeLinks = postTreeGroup.selectAll(".post-tree-link")
@@ -2067,21 +2126,64 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                              ${{d.target.x}},${{d.target.y}}`;
                 }})
                 .attr("fill", "none")
-                .attr("stroke", "#9b59b6")
+                .attr("stroke", d => {{
+                    // 边颜色与圆形图统一
+                    if (d.target.data.nodeType === "人设") {{
+                        // 匹配边用红色
+                        return "#e94560";
+                    }}
+                    if (d.target.data.nodeType === "人设扩展") {{
+                        // 扩展边根据边类型决定颜色(与圆形图一致)
+                        const edgeType = d.target.data.edgeType || "";
+                        const edgeColors = {{
+                            "属于": "#9b59b6",           // 紫色
+                            "包含": "#ffb6c1",           // 淡粉
+                            "分类共现(跨点)": "#2ecc71", // 绿色
+                            "分类共现(点内)": "#3498db", // 蓝色
+                            "标签共现": "#f39c12"        // 橙色
+                        }};
+                        return edgeColors[edgeType] || "#9b59b6";
+                    }}
+                    return "#9b59b6";
+                }})
                 .attr("stroke-width", 1.5)
-                .attr("stroke-opacity", 0.6);
+                .attr("stroke-opacity", 0.6)
+                .attr("stroke-dasharray", d => {{
+                    // 相似匹配用虚线
+                    if (d.target.data.nodeType === "人设" && d.target.data.matchType === "相似") {{
+                        return "4,2";
+                    }}
+                    return "none";
+                }});
+
+            // 只显示权重和相似度分数,直接在边的中间显示
 
-            // 在点->标签的边上显示权重(浮点数)
+            // 在点->标签的边上显示权重(居中
             postTreeLinks.filter(d => d.target.data.nodeType === "标签" && d.target.data.weight > 0)
                 .append("text")
                 .attr("x", d => (d.source.x + d.target.x) / 2)
                 .attr("y", d => (d.source.y + d.target.y) / 2)
+                .attr("dy", "0.35em")
                 .attr("text-anchor", "middle")
-                .attr("fill", "#9b59b6")
-                .attr("font-size", "9px")
+                .attr("fill", "#fff")
+                .attr("font-size", "8px")
                 .attr("font-weight", "bold")
+                .style("text-shadow", "0 0 3px #9b59b6, 0 0 3px #9b59b6")
                 .text(d => d.target.data.weight.toFixed(1));
 
+            // 在标签->人设的边上显示相似度分数(居中)
+            postTreeLinks.filter(d => d.target.data.nodeType === "人设" && d.target.data.similarity > 0)
+                .append("text")
+                .attr("x", d => (d.source.x + d.target.x) / 2)
+                .attr("y", d => (d.source.y + d.target.y) / 2)
+                .attr("dy", "0.35em")
+                .attr("text-anchor", "middle")
+                .attr("fill", "#fff")
+                .attr("font-size", "8px")
+                .attr("font-weight", "bold")
+                .style("text-shadow", "0 0 3px #e94560, 0 0 3px #e94560")
+                .text(d => d.target.data.similarity.toFixed(2))
+
             // 六边形路径生成函数
             function hexagonPath(r) {{
                 const a = Math.PI / 3;
@@ -2103,36 +2205,78 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("class", "post-tree-node")
                 .attr("transform", d => `translate(${{d.x}},${{d.y}})`);
 
-            // 节点形状(维度=大圆,点=六边形,标签=小圆)
+            // 节点形状和大小(与圆形图统一)
+            // 点节点:六边形 size=16,标签:圆形 size=12,人设:圆形 size=10
             postTreeNodes.each(function(d) {{
                 const node = d3.select(this);
+                const fill = d.data.dimColor || "#888";
+
                 if (d.data.nodeType === "点") {{
-                    // 点节点用六边形
+                    // 点节点用六边形,size=16(与圆形图一致)
                     node.append("path")
-                        .attr("d", hexagonPath(6))
-                        .attr("fill", d.data.dimColor || "#888")
+                        .attr("d", hexagonPath(16))
+                        .attr("fill", fill)
                         .attr("stroke", "#fff")
-                        .attr("stroke-width", 1);
+                        .attr("stroke-width", 2);
+                }} else if (d.data.isDimension) {{
+                    // 维度节点用大圆
+                    node.append("circle")
+                        .attr("r", 14)
+                        .attr("fill", fill)
+                        .attr("stroke", "#fff")
+                        .attr("stroke-width", 2);
+                }} else if (d.data.nodeType === "标签") {{
+                    // 标签节点,size=12(与圆形图帖子标签一致)
+                    node.append("circle")
+                        .attr("r", 12)
+                        .attr("fill", fill)
+                        .attr("stroke", "#fff")
+                        .attr("stroke-width", 2);
+                }} else if (d.data.nodeType === "人设" || d.data.nodeType === "人设扩展") {{
+                    // 人设节点和扩展节点,根据原始类型决定形状(与圆形图一致)
+                    const size = 10;
+                    if (d.data.originalType === "分类") {{
+                        // 分类用方形
+                        node.append("rect")
+                            .attr("width", size * 2)
+                            .attr("height", size * 2)
+                            .attr("x", -size)
+                            .attr("y", -size)
+                            .attr("fill", fill)
+                            .attr("stroke", "#fff")
+                            .attr("stroke-width", 2)
+                            .attr("rx", 3);
+                    }} else {{
+                        // 标签用圆形
+                        node.append("circle")
+                            .attr("r", size)
+                            .attr("fill", fill)
+                            .attr("stroke", "#fff")
+                            .attr("stroke-width", 2);
+                    }}
                 }} else {{
-                    // 维度和标签用圆形
+                    // 其他节点默认
                     node.append("circle")
-                        .attr("r", d.data.isDimension ? 7 : 3)
-                        .attr("fill", d.data.dimColor || "#888")
+                        .attr("r", 8)
+                        .attr("fill", fill)
                         .attr("stroke", "#fff")
                         .attr("stroke-width", 1);
                 }}
             }});
 
-            // 节点标签
+            // 节点标签(统一放在节点上方)
             postTreeNodes.append("text")
-                .attr("dy", d => d.children ? -10 : 12)
+                .attr("dy", d => {{
+                    // 根据节点类型调整偏移量,统一放在节点上方
+                    if (d.data.isDimension) return -22;
+                    if (d.data.nodeType === "点") return -24;
+                    if (d.data.nodeType === "标签") return -20;
+                    if (d.data.nodeType === "人设" || d.data.nodeType === "人设扩展") return -18;
+                    return -18;
+                }})
                 .attr("text-anchor", "middle")
                 .attr("fill", d => d.data.dimColor || "#ccc")
-                .attr("font-size", d => {{
-                    if (d.data.isDimension) return "11px";
-                    if (d.data.nodeType === "点") return "10px";
-                    return "8px";
-                }})
+                .attr("font-size", "11px")
                 .attr("font-weight", d => d.data.isDimension ? "bold" : "normal")
                 .text(d => d.data.name || "");
 
@@ -2178,19 +2322,25 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             rootCard.append("xhtml:div").html(cardHtml);
 
             postTreeActualHeight = treeMaxY - treeMinY;
+
+            // 应用帖子树的位置变换(放在圆的右侧)
+            postTreeGroup.attr("transform", `translate(${{postTreeOffsetX}}, ${{postTreeOffsetY}})`);
             }} // end if (postTree && postTree.root)
 
-            // 根据帖子树高度,调整圆的Y位置
-            const postTreeTotalHeight = postTreeOffsetY + postTreeActualHeight + 80;
-            const circleYOffset = postTreeTotalHeight;
+            // 圆的Y位置不再受帖子树高度影响,使用固定偏移
+            const circleYOffset = 40;
 
             // 更新圆心Y坐标(三层)
             layerCenterY[0] += circleYOffset;
             layerCenterY[1] += circleYOffset;
             layerCenterY[2] += circleYOffset;
 
-            // 更新SVG高度
-            svg.attr("height", height + circleYOffset);
+            // 更新SVG高度(取圆和帖子树的最大高度)
+            const minHeight = Math.max(
+                layerCenterY[2] + layerRadius[2] + 50,
+                postTreeActualHeight + 150
+            );
+            svg.attr("height", Math.max(height, minHeight));
 
             // 绘制层背景(圆形,使用动态大小)
             const layerBg = g.append("g").attr("class", "layer-backgrounds");