Explorar o código

feat: 统一边的score字段和显示逻辑

- 添加全局getEdgeScore函数统一提取边的分数(相似度/Jaccard/重叠系数)
- 左边圆、右边树内边、右边跨层边、相关图统一显示边的score
- 左边圆边去重(同一对节点相同类型只保留一条)
- 分数和边绑定在同一个g元素内,隐藏/高亮同步
- 相关图hover时分数跟随边一起隐藏/显示

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui hai 5 días
pai
achega
e52d53959c
Modificáronse 1 ficheiros con 108 adicións e 39 borrados
  1. 108 39
      script/data_processing/visualize_match_graph.py

+ 108 - 39
script/data_processing/visualize_match_graph.py

@@ -1051,24 +1051,6 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         </div>
                     </div>
                 </div>
-
-                <div class="controls">
-                    <div class="control-label">视图控制</div>
-                    <button onclick="resetZoom()">重置视图</button>
-                    <button onclick="toggleLabels()">切换标签</button>
-                    <button onclick="toggleCrossLayerEdges()" id="crossLayerBtn">显示跨层边</button>
-                    <div class="control-group">
-                        <div class="control-label">人设树配置</div>
-                        <button onclick="toggleTreeTags()" id="treeTagBtn" class="active">显示标签</button>
-                        <select id="treeDepthSelect" onchange="setTreeDepth(this.value)">
-                            <option value="0">全部层级</option>
-                            <option value="2">2层</option>
-                            <option value="3">3层</option>
-                            <option value="4">4层</option>
-                            <option value="5">5层</option>
-                        </select>
-                    </div>
-                </div>
             </div>
         </div>
     </div>
@@ -1379,6 +1361,16 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             return {{ root: rootNode, nodeCount: totalNodeCount, tagMap: tagMap }};
         }}
 
+        // 统一提取边的分数字段(全局函数)
+        function getEdgeScore(e) {{
+            if (!e.边详情) return null;
+            // 优先级:相似度 > Jaccard相似度 > 重叠系数
+            if (e.边详情.相似度 !== undefined) return e.边详情.相似度;
+            if (e.边详情.Jaccard相似度 !== undefined) return e.边详情.Jaccard相似度;
+            if (e.边详情.重叠系数 !== undefined) return e.边详情.重叠系数;
+            return null;
+        }}
+
         // 显示帖子详情模态框
         function showPostDetail(postData) {{
             if (!postData) return;
@@ -1494,16 +1486,25 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             }}
 
             // 过滤掉引用帖子点节点的边
-            const links = data.edges
+            const rawLinks = data.edges
                 .filter(e => !isPostPointId(e.源节点ID) && !isPostPointId(e.目标节点ID))
                 .map(e => ({{
                     ...e,
                     source: e.源节点ID,
                     target: e.目标节点ID,
                     type: e.边类型,
-                    similarity: e.边详情 ? e.边详情.相似度 : 0
+                    score: getEdgeScore(e)
                 }}));
 
+            // 去重:同一对节点之间相同类型的边只保留一条
+            const seenEdgeKeys = new Set();
+            const links = rawLinks.filter(e => {{
+                const edgeKey = [e.source, e.target].sort().join("|") + "|" + e.type;
+                if (seenEdgeKeys.has(edgeKey)) return false;
+                seenEdgeKeys.add(edgeKey);
+                return true;
+            }});
+
             // 分离节点类型
             const postNodes = nodes.filter(n => n.source === "帖子");
             const personaNodes = nodes.filter(n => n.source === "人设" && !n.是否扩展);
@@ -2061,9 +2062,21 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         return Math.max(minSep, defaultSep);
                     }});
 
-                // 过滤掉人设节点,只保留树结构
+                // 从原始节点数据中构建权重映射(标签节点有权重字段)
+                const nodeWeightMap = {{}};
+                nodes.forEach(n => {{
+                    if (n.权重 !== undefined) {{
+                        nodeWeightMap[n.id] = n.权重;
+                    }}
+                }});
+
+                // 过滤掉人设节点,只保留树结构,并补充权重
                 function filterTreeData(node) {{
                     const filtered = {{ ...node }};
+                    // 补充权重信息
+                    if (node.id && nodeWeightMap[node.id] !== undefined) {{
+                        filtered.权重 = nodeWeightMap[node.id];
+                    }}
                     if (node.children) {{
                         filtered.children = node.children
                             .filter(c => c.nodeType !== "人设" && c.nodeType !== "人设扩展")
@@ -2128,11 +2141,21 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                              ${{d.target.x}},${{d.target.y}}`;
                 }};
 
-                // 绘制树的边(帖子→维度→点→标签)- 无点击事件
-                const treeLinks = postTreeGroup.selectAll(".tree-link")
-                    .data(postRoot.links())
+                // 为树边添加score字段(从目标节点的权重获取)
+                const treeLinkData = postRoot.links().map(d => {{
+                    d.score = d.target.data.权重 !== undefined ? d.target.data.权重 : null;
+                    return d;
+                }});
+
+                // 绘制树的边(帖子→维度→点→标签)- 用g元素包裹边和分数
+                const treeLinkGroups = postTreeGroup.selectAll(".tree-link-group")
+                    .data(treeLinkData)
                     .enter()
-                    .append("path")
+                    .append("g")
+                    .attr("class", "tree-link-group");
+
+                // 边的路径
+                const treeLinks = treeLinkGroups.append("path")
                     .attr("class", "tree-link")
                     .attr("d", treeLinkPath)
                     .attr("fill", "none")
@@ -2140,6 +2163,20 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .attr("stroke-width", 1.5)
                     .attr("stroke-opacity", 0.6);
 
+                // 在有分数的边上显示分数(统一逻辑,居中显示在边上)
+                treeLinkGroups.filter(d => d.score !== null)
+                    .append("text")
+                    .attr("class", "tree-link-score")
+                    .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 #9b59b6, 0 0 3px #9b59b6")
+                    .text(d => d.score.toFixed(2));
+
                 // 绘制树节点(非根节点)
                 const treeNodes = postTreeGroup.selectAll(".tree-node")
                     .data(postRoot.descendants().filter(d => !d.data.isRoot))
@@ -2324,6 +2361,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                              ${{d.target.x}},${{d.target.y}}`;
                 }});
 
+                // 更新分数文本位置(因为标签节点位置可能变了)
+                postTreeGroup.selectAll(".tree-link-score")
+                    .attr("x", d => (d.source.x + d.target.x) / 2)
+                    .attr("y", d => (d.source.y + d.target.y) / 2);
+
                 // ===== 下半部分:三分图(标签 ↔ 人设_1层 ↔ 人设_2层)=====
                 // 复用圆形图的节点数据,但只取人设节点(去重)
                 const personaLayer1Nodes = nodes.filter(n => getNodeLayer(n) === 1);
@@ -2651,8 +2693,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         }}
                     }});
 
-                // 在匹配边上显示相似度
-                graphLinks.filter(d => d.type && d.type.startsWith("匹配_") && d.similarity > 0)
+                // 在有分数的边上显示分数(统一逻辑)
+                graphLinks.filter(d => d.score !== null && d.score !== undefined)
                     .append("text")
                     .attr("x", d => (d.srcNode.x + d.tgtNode.x) / 2)
                     .attr("y", d => (d.srcNode.y + d.tgtNode.y) / 2)
@@ -2662,7 +2704,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .attr("font-size", "8px")
                     .attr("font-weight", "bold")
                     .style("text-shadow", "0 0 3px #e94560, 0 0 3px #e94560")
-                    .text(d => d.similarity ? d.similarity.toFixed(2) : "");
+                    .text(d => d.score.toFixed(2));
 
                 // 绘制人设节点(去重后的)
                 const allPersonaNodes = [...personaLayer1Nodes, ...personaLayer2Nodes];
@@ -3033,8 +3075,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 }}
             }});
 
-            // 为匹配边添加分数标签
-            const edgeLabels = linkG.filter(d => d.type.startsWith("匹配_") && d.边详情 && d.边详情.相似度)
+            // 为有分数的边添加分数标签(统一逻辑)
+            const edgeLabels = linkG.filter(d => d.score !== null && d.score !== undefined)
                 .append("g")
                 .attr("class", "edge-label-group");
 
@@ -3045,10 +3087,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             edgeLabels.append("text")
                 .attr("class", "edge-label")
-                .text(d => {{
-                    const score = d.边详情.相似度;
-                    return typeof score === "number" ? score.toFixed(2) : score;
-                }});
+                .text(d => d.score.toFixed(2));
 
             // 边的点击事件
             linkHitarea.on("click", (event, d, i) => {{
@@ -4163,7 +4202,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         source: e.源节点ID,
                         target: e.目标节点ID,
                         type: e.边类型,
-                        curvature: curvature
+                        curvature: curvature,
+                        score: getEdgeScore(e)
                     }});
                 }});
             }});
@@ -4312,6 +4352,18 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 .attr("stroke-opacity", 0.7)
                 .attr("fill", "none");
 
+            // 在有分数的边上显示分数(统一逻辑)
+            const edgeScoreText = egoGroup.selectAll(".ego-edge-score")
+                .data(links.filter(d => d.score !== null && d.score !== undefined))
+                .join("text")
+                .attr("class", "ego-edge-score")
+                .attr("text-anchor", "middle")
+                .attr("fill", "#fff")
+                .attr("font-size", "8px")
+                .attr("font-weight", "bold")
+                .style("text-shadow", "0 0 3px #333, 0 0 3px #333")
+                .text(d => d.score.toFixed(2));
+
             // 绘制节点(分类用方形,标签用圆形)
             const node = egoGroup.selectAll(".ego-node")
                 .data(nodes)
@@ -4407,6 +4459,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             simulation.on("tick", () => {{
                 link.attr("d", linkPath);
                 node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+                // 更新分数位置(边的中点)
+                edgeScoreText
+                    .attr("x", d => (d.source.x + d.target.x) / 2)
+                    .attr("y", d => (d.source.y + d.target.y) / 2);
             }});
         }}
 
@@ -4499,6 +4555,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     return pathEdgeKeys.has(key) ? "visible" : "hidden";
                 }});
 
+            // 隐藏不在路径上的边的分数
+            egoGroup.selectAll(".ego-edge-score")
+                .style("visibility", function(d) {{
+                    const srcId = d.source.id || d.source;
+                    const tgtId = d.target.id || d.target;
+                    const key = `${{srcId}}|${{tgtId}}`;
+                    return pathEdgeKeys.has(key) ? "visible" : "hidden";
+                }});
+
             // 隐藏不在路径上的节点(保持布局不变)
             egoNodes
                 .style("visibility", function(d) {{
@@ -4518,6 +4583,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             egoGroup.selectAll(".ego-edge")
                 .style("visibility", "visible");
 
+            // 恢复边分数的可见性
+            egoGroup.selectAll(".ego-edge-score")
+                .style("visibility", "visible");
+
             // 恢复节点的可见性
             egoGroup.selectAll(".ego-node")
                 .style("visibility", "visible");
@@ -4538,8 +4607,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                         return pathNodes.has(nodeId) ? 1 : 0.2;
                     }});
 
-                // 高亮帖子树边
-                postTreeGroup.selectAll(".tree-link")
+                // 高亮帖子树边(包含边和分数的组)
+                postTreeGroup.selectAll(".tree-link-group")
                     .style("opacity", function(d) {{
                         const srcId = d.source.data.id || d.source.data.节点ID;
                         const tgtId = d.target.data.id || d.target.data.节点ID;
@@ -4570,10 +4639,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         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);
+                postTreeGroup.selectAll(".tree-link-group").style("opacity", 1);
             }}
 
             // 恢复人设节点(在g下面)