Jelajahi Sumber

feat: 优化布局,人设树和关系图分离

- 左侧面板分为上下两部分:人设树(可滚动)+ 关系图(固定高度)
- 人设树使用独立SVG,支持滚动浏览
- 关系图使用独立SVG,点击人设树节点时显示
- 关系图支持缩放和拖拽
- 右侧圆形层减为3层(帖子点、帖子标签、匹配人设)

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

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui 5 hari lalu
induk
melakukan
94065cee12
1 mengubah file dengan 125 tambahan dan 48 penghapusan
  1. 125 48
      script/data_processing/visualize_match_graph.py

+ 125 - 48
script/data_processing/visualize_match_graph.py

@@ -77,6 +77,29 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             flex: 1;
             overflow: hidden;
         }}
+        #left-panel {{
+            width: 420px;
+            display: flex;
+            flex-direction: column;
+            background: #1a1a2e;
+            border-right: 1px solid #0f3460;
+        }}
+        #tree-container {{
+            flex: 1;
+            overflow-y: auto;
+            overflow-x: hidden;
+        }}
+        #tree-container svg {{
+            display: block;
+        }}
+        #ego-container {{
+            height: 420px;
+            border-top: 1px solid #0f3460;
+            display: none;
+        }}
+        #ego-container svg {{
+            display: block;
+        }}
         #graph {{
             flex: 1;
             position: relative;
@@ -490,6 +513,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             {tabs_html}
         </div>
         <div class="main-content">
+            <div id="left-panel">
+                <div id="tree-container"></div>
+                <div id="ego-container"></div>
+            </div>
             <div id="graph">
                 <div class="tooltip" id="tooltip"></div>
             </div>
@@ -577,6 +604,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         let simulation = null;
         let svg = null;
         let g = null;
+        let treeSvg = null;  // 人设树独立SVG
+        let treeG = null;    // 人设树group
+        let egoSvg = null;   // 关系图独立SVG
+        let egoG = null;     // 关系图group
         let zoom = null;
         let showLabels = true;
         let showCrossLayerEdges = false;  // 跨层边默认隐藏
@@ -606,9 +637,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         // 初始化
         function init() {{
             const container = document.getElementById("graph");
+            const treeContainer = document.getElementById("tree-container");
             const width = container.clientWidth;
             const height = container.clientHeight;
 
+            // 创建圆形层SVG
             svg = d3.select("#graph")
                 .append("svg")
                 .attr("width", width)
@@ -624,6 +657,30 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             svg.call(zoom);
 
+            // 创建人设树SVG(独立可滚动)
+            treeSvg = d3.select("#tree-container")
+                .append("svg")
+                .attr("width", 400);
+
+            treeG = treeSvg.append("g");
+
+            // 创建关系图SVG(在独立的ego-container中)
+            egoSvg = d3.select("#ego-container")
+                .append("svg")
+                .attr("width", 400)
+                .attr("height", 400);
+
+            egoG = egoSvg.append("g");
+
+            // 关系图缩放
+            const egoZoom = d3.zoom()
+                .scaleExtent([0.3, 3])
+                .on("zoom", (event) => {{
+                    egoG.attr("transform", event.transform);
+                }});
+
+            egoSvg.call(egoZoom);
+
             // 绑定Tab点击事件
             document.querySelectorAll(".tab").forEach((tab, index) => {{
                 tab.addEventListener("click", () => switchTab(index));
@@ -736,6 +793,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         function renderGraph(data) {{
             // 清空现有图谱
             g.selectAll("*").remove();
+            treeG.selectAll("*").remove();
             if (simulation) {{
                 simulation.stop();
             }}
@@ -743,12 +801,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const container = document.getElementById("graph");
             const width = container.clientWidth;
 
-            // 布局配置:左侧人设树,右侧三层圆形
-            // 树宽度自适应:占总宽度的35%,最小400,最大550
-            const treeAreaWidth = Math.max(400, Math.min(550, width * 0.35));
-            const circleAreaLeft = treeAreaWidth + 40;  // 圆形区域起始X
-            const circleAreaWidth = width - circleAreaLeft - 50;
-            const circleAreaCenterX = circleAreaLeft + circleAreaWidth / 2;
+            // 布局配置:人设树在左侧独立容器,右侧是圆形层
+            const treeAreaWidth = 400;  // 固定树宽度
+            const circleAreaWidth = width - 40;
+            const circleAreaCenterX = circleAreaWidth / 2;
 
             // 准备数据
             const nodes = data.nodes.map(n => ({{
@@ -1003,18 +1059,16 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             const layerRadius = {{
                 0: calcRadius(layerCounts[0]),
                 1: calcRadius(layerCounts[1]),
-                2: calcRadius(layerCounts[2]),
-                3: 250  // 关系图层默认半径(更大,容纳更多节点)
+                2: calcRadius(layerCounts[2])
             }};
             console.log("每层半径:", layerRadius);
 
-            // 计算四层的高度和圆心Y坐标
+            // 计算三层的高度和圆心Y坐标(关系图移到左侧)
             const layerPadding = 50;
             const layerHeights = {{
                 0: layerRadius[0] * 2 + layerPadding,
                 1: layerRadius[1] * 2 + layerPadding,
-                2: layerRadius[2] * 2 + layerPadding,
-                3: layerRadius[3] * 2 + layerPadding
+                2: layerRadius[2] * 2 + layerPadding
             }};
 
             // 获取人设树数据(单棵树)
@@ -1022,8 +1076,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 树高度自适应:根据节点数量,每个节点约12px,最小800
             const treeHeight = Math.max(800, personaTree.nodeCount * 12 + 150);
 
-            // 计算总高度(取圆形和树的最大值)
-            const circleHeight = layerHeights[0] + layerHeights[1] + layerHeights[2] + layerHeights[3];
+            // 关系图配置(放在树下方)
+            const egoRadius = 180;
+            const egoAreaHeight = egoRadius * 2 + 80;
+
+            // 计算总高度(取圆形和树+关系图的最大值)
+            const circleHeight = layerHeights[0] + layerHeights[1] + layerHeights[2];
             const height = Math.max(circleHeight + 100, treeHeight + 80, container.clientHeight);
 
             // 计算每层圆心坐标(在右侧区域)
@@ -1039,9 +1097,6 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             currentY += layerRadius[1] + layerPadding + layerRadius[2];
             layerCenterY[2] = currentY;
             layerCenterX[2] = circleAreaCenterX;
-            currentY += layerRadius[2] + layerPadding + layerRadius[3];
-            layerCenterY[3] = currentY;
-            layerCenterX[3] = circleAreaCenterX;
 
             console.log("每层圆心X:", layerCenterX);
             console.log("每层圆心Y:", layerCenterY);
@@ -1271,15 +1326,14 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 绘制层背景(圆形,使用动态大小)
             const layerBg = g.append("g").attr("class", "layer-backgrounds");
 
-            // 层配置:名称、颜色(四层圆
+            // 层配置:名称、颜色(三层圆,关系图在左侧
             const layerConfig = [
                 {{ name: "帖子点", layer: 0, color: "rgba(243, 156, 18, 0.08)", stroke: "rgba(243, 156, 18, 0.3)" }},
                 {{ name: "帖子标签", layer: 1, color: "rgba(52, 152, 219, 0.08)", stroke: "rgba(52, 152, 219, 0.3)" }},
-                {{ name: "人设", layer: 2, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }},
-                {{ name: "关系图", layer: 3, color: "rgba(233, 69, 96, 0.08)", stroke: "rgba(233, 69, 96, 0.3)" }}
+                {{ name: "人设", layer: 2, color: "rgba(155, 89, 182, 0.08)", stroke: "rgba(155, 89, 182, 0.3)" }}
             ];
 
-            // 绘制层圆形背景
+            // 绘制层圆形背景
             layerConfig.forEach(cfg => {{
                 const cx = layerCenterX[cfg.layer];
                 const cy = layerCenterY[cfg.layer];
@@ -1303,33 +1357,48 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     .attr("font-weight", "bold")
                     .attr("text-anchor", "end")
                     .text(cfg.name);
-
-                // 关系图层添加占位提示
-                if (cfg.layer === 3) {{
-                    layerBg.append("text")
-                        .attr("class", "ego-placeholder")
-                        .attr("x", cx)
-                        .attr("y", cy)
-                        .attr("dy", "0.35em")
-                        .attr("fill", "rgba(255,255,255,0.3)")
-                        .attr("font-size", "12px")
-                        .attr("text-anchor", "middle")
-                        .text("点击左侧人设树节点查看关系");
-                }}
             }});
 
-            // 创建关系图层内容组(动态填充)
-            const egoGraphGroup = g.append("g")
-                .attr("class", "ego-graph-content")
-                .attr("transform", `translate(${{layerCenterX[3]}}, ${{layerCenterY[3]}})`);
-
-            // 绘制左侧人设树(单棵树,根节点为"人设")
+            // 绘制左侧人设树(在独立的treeSvg中)
             const dimColors = {{ "灵感点": "#f39c12", "目的点": "#3498db", "关键点": "#9b59b6" }};
 
-            const treeGroup = g.append("g")
+            // 更新树SVG高度
+            treeSvg.attr("height", treeHeight + 50);
+
+            const treeGroup = treeG.append("g")
                 .attr("class", "persona-tree")
                 .attr("transform", `translate(15, 25)`);
 
+            // 设置关系图SVG
+            egoG.selectAll("*").remove();
+
+            const egoDisplayRadius = 180;
+            const egoCenterGroup = egoG.append("g")
+                .attr("class", "ego-center")
+                .attr("transform", `translate(200, 210)`);
+
+            // 关系图背景圆
+            egoCenterGroup.append("circle")
+                .attr("class", "ego-background")
+                .attr("r", egoDisplayRadius)
+                .attr("fill", "rgba(233, 69, 96, 0.08)")
+                .attr("stroke", "rgba(233, 69, 96, 0.3)")
+                .attr("stroke-width", 2);
+
+            // 关系图标题
+            egoCenterGroup.append("text")
+                .attr("class", "ego-title")
+                .attr("y", -egoDisplayRadius - 10)
+                .attr("text-anchor", "middle")
+                .attr("fill", "rgba(255,255,255,0.5)")
+                .attr("font-size", "13px")
+                .attr("font-weight", "bold")
+                .text("关系图");
+
+            // 关系图内容组
+            egoCenterGroup.append("g")
+                .attr("class", "ego-graph-content");
+
             // 绘制背景矩形
             treeGroup.append("rect")
                 .attr("x", -5)
@@ -2330,6 +2399,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
         let currentEgoSimulation = null;  // 保存当前的力模拟,用于停止
 
         function renderEgoGraph(centerNodeId, centerNodeName) {{
+            // 显示关系图容器
+            document.getElementById("ego-container").style.display = "block";
+
             // 获取关系图层组
             const egoGroup = d3.select(".ego-graph-content");
             if (egoGroup.empty()) return;
@@ -2343,11 +2415,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             // 清除旧内容
             egoGroup.selectAll("*").remove();
 
-            // 隐藏占位提示
-            d3.select(".ego-placeholder").style("display", "none");
+            // 更新标题
+            d3.select(".ego-container .ego-title").text(`关系图: ${{centerNodeName}}`);
 
-            // 获取层半径(需要从全局获取或重新计算)
-            const radius = 120;  // 固定半径
+            // 获取层半径
+            const radius = 160;  // 固定半径
 
             // 找到所有相关的边
             const relatedEdges = personaTreeData.edges.filter(e =>
@@ -2568,8 +2640,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             if (!egoGroup.empty()) {{
                 egoGroup.selectAll("*").remove();
             }}
-            // 显示占位提示
-            d3.select(".ego-placeholder").style("display", null);
+            // 隐藏关系图容器
+            document.getElementById("ego-container").style.display = "none";
             // 停止模拟
             if (currentEgoSimulation) {{
                 currentEgoSimulation.stop();
@@ -2579,6 +2651,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
         // 渲染单条边和两个节点(点击树边时调用)
         function renderEgoGraphEdge(edgeData, sourceNode, targetNode) {{
+            // 显示关系图容器
+            document.getElementById("ego-container").style.display = "block";
+
             const egoGroup = d3.select(".ego-graph-content");
             if (egoGroup.empty()) return;
 
@@ -2590,9 +2665,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
 
             // 清除旧内容
             egoGroup.selectAll("*").remove();
-            d3.select(".ego-placeholder").style("display", "none");
 
-            const radius = 250;
+            // 更新标题
+            d3.select(".ego-container .ego-title").text(`关系图: ${{edgeData.边类型}}`);
+
+            const radius = 160;
 
             // 边类型颜色
             const edgeColors = {{