소스 검색

fix: 修复同名标签多位置边绘制问题

- tagNodePositions 改为存储位置数组,支持同一标签ID多个位置
- 展开边数据,为每个标签位置创建独立的边
- 修复层背景遮挡边的问题(layerBg.lower())
- 优化人设节点位置计算,考虑多个标签位置的平均值

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

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

+ 407 - 265
script/data_processing/visualize_match_graph.py

@@ -1500,7 +1500,8 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                     ...e,
                     source: e.源节点ID,
                     target: e.目标节点ID,
-                    type: e.边类型
+                    type: e.边类型,
+                    similarity: e.边详情 ? e.边详情.相似度 : 0
                 }}));
 
             // 分离节点类型
@@ -2011,232 +2012,420 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                 return "match";
             }}
 
-            // ===== 帖子树放在圆的右侧,与圆层对齐 =====
+            // ===== 右侧:帖子树(上半部分) + 三分图(下半部分) =====
             const postDetail = data.postDetail || {{}};
             window.currentPostDetail = postDetail;
 
             // 使用预构建的帖子树数据
             const postTree = data.postTree || {{ root: null }};
 
-            // 帖子树D3布局(只在有帖子树数据时绘制)
+            // 容器
             const postTreeGroup = g.append("g").attr("class", "post-tree");
             let postTreeActualHeight = 0;
             let postRoot = null;
 
-            // 卡片尺寸(提前定义)
+            // 卡片尺寸
             const cardWidth = 240;
             const cardHeight = 90;
 
-            if (postTree && postTree.root) {{
-            const postTreeLayout = d3.tree()
-                .nodeSize([50, 80])
-                .separation((a, b) => a.parent === b.parent ? 1.2 : 1.5);
-
-            postRoot = d3.hierarchy(postTree.root);
-            postTreeLayout(postRoot);
-
-            // 计算树的实际边界
-            let treeMinX = Infinity, treeMaxX = -Infinity, treeMinY = Infinity, treeMaxY = -Infinity;
-            postRoot.descendants().forEach(d => {{
-                treeMinX = Math.min(treeMinX, d.x);
-                treeMaxX = Math.max(treeMaxX, d.x);
-                treeMinY = Math.min(treeMinY, d.y);
-                treeMaxY = Math.max(treeMaxY, d.y);
-            }});
-
-            // 找出各层的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;
-            }});
-
-            // 计算目标圆心Y坐标(加上circleYOffset后的实际值)
+            // 计算目标圆心Y坐标
             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 halfGap = circleGap / 2;
 
-            // 计算层间距
-            const circleGap = targetPersonaY - targetTagY;  // 两个圆心之间的距离
-            const hasExpandedLayer = expandedLayerY > 0;
+            // 树放在圆的右侧
+            const circleRightEdge = circleAreaCenterX + unifiedRadius;
+            const treeGap = 120;
 
-            // 重新分配Y坐标
-            // 前3层(帖子、维度、点)层高是后面层高的一半
-            // 后3层(标签、人设、人设扩展)对齐三个圆心
-            const halfGap = circleGap / 2;
+            // 记录标签节点的位置(用于下方图的对齐)
+            const tagNodePositions = {{}};
 
-            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;
+            if (postTree && postTree.root) {{
+                // D3树布局(只用于帖子→维度→点→标签)
+                const postTreeLayout = d3.tree()
+                    .nodeSize([50, 80])
+                    .separation((a, b) => a.parent === b.parent ? 1.2 : 1.5);
+
+                // 过滤掉人设节点,只保留树结构
+                function filterTreeData(node) {{
+                    const filtered = {{ ...node }};
+                    if (node.children) {{
+                        filtered.children = node.children
+                            .filter(c => c.nodeType !== "人设" && c.nodeType !== "人设扩展")
+                            .map(c => filterTreeData(c));
+                    }}
+                    return filtered;
                 }}
-            }});
+                const treeOnlyData = filterTreeData(postTree.root);
 
-            // 更新边界(因为Y坐标变了)
-            treeMinY = Infinity; treeMaxY = -Infinity;
-            postRoot.descendants().forEach(d => {{
-                treeMinY = Math.min(treeMinY, d.y);
-                treeMaxY = Math.max(treeMaxY, d.y);
-            }});
+                postRoot = d3.hierarchy(treeOnlyData);
+                postTreeLayout(postRoot);
 
-            // 树放在圆的右侧,确保树的最左边与圆的最右边有间隔
-            const circleRightEdge = circleAreaCenterX + unifiedRadius;
-            const treeGap = 120;  // 圆右边与树左边的间隔
-            const postTreeOffsetX = circleRightEdge + treeGap - treeMinX;
+                // 计算树的边界
+                let treeMinX = Infinity, treeMaxX = -Infinity;
+                postRoot.descendants().forEach(d => {{
+                    treeMinX = Math.min(treeMinX, d.x);
+                    treeMaxX = Math.max(treeMaxX, d.x);
+                }});
 
-            // Y方向不需要额外偏移,因为已经直接设置了目标Y坐标
-            const postTreeOffsetY = 0;
+                // 重新分配Y坐标(前3层层高是后面的一半)
+                postRoot.descendants().forEach(d => {{
+                    if (d.data.isRoot) {{
+                        d.y = targetTagY - halfGap * 3;
+                    }} else if (d.data.isDimension) {{
+                        d.y = targetTagY - halfGap * 2;
+                    }} else if (d.data.nodeType === "点") {{
+                        d.y = targetTagY - halfGap;
+                    }} else if (d.data.nodeType === "标签") {{
+                        d.y = targetTagY;
+                        // 记录标签节点位置(同一个ID可能有多个位置)
+                        if (!tagNodePositions[d.data.id]) {{
+                            tagNodePositions[d.data.id] = [];
+                        }}
+                        tagNodePositions[d.data.id].push({{ x: d.x, y: d.y, data: d.data }});
+                    }}
+                }});
 
-            // 绘制帖子树边(属于边,紫色,粗细根据权重)
-            const postTreeLinks = postTreeGroup.selectAll(".post-tree-link")
-                .data(postRoot.links())
-                .enter()
-                .append("g")
-                .attr("class", "post-tree-link-group");
-
-            postTreeLinks.append("path")
-                .attr("class", "post-tree-link")
-                .attr("d", d => {{
-                    // 如果源节点是根节点(帖子卡片),从卡片下边框中心出发
-                    const sourceY = d.source.data.isRoot ? d.source.y + cardHeight/2 : d.source.y;
-                    return `M${{d.source.x}},${{sourceY}}
-                            C${{d.source.x}},${{(sourceY + d.target.y) / 2}}
-                             ${{d.target.x}},${{(sourceY + d.target.y) / 2}}
-                             ${{d.target.x}},${{d.target.y}}`;
-                }})
-                .attr("fill", "none")
-                .attr("stroke", d => {{
-                    // 边颜色与圆形图统一
-                    if (d.target.data.nodeType === "人设") {{
-                        // 匹配边用红色
-                        return "#e94560";
+                // 计算偏移
+                const postTreeOffsetX = circleRightEdge + treeGap - treeMinX;
+                const postTreeOffsetY = 0;
+
+                // 六边形路径
+                function hexagonPath(r) {{
+                    const a = Math.PI / 3;
+                    let path = "";
+                    for (let i = 0; i < 6; i++) {{
+                        const angle = a * i - Math.PI / 2;
+                        const x = r * Math.cos(angle);
+                        const y = r * Math.sin(angle);
+                        path += (i === 0 ? "M" : "L") + x + "," + y;
                     }}
-                    if (d.target.data.nodeType === "人设扩展") {{
-                        // 扩展边根据边类型决定颜色(与圆形图一致)
-                        const edgeType = d.target.data.edgeType || "";
-                        const edgeColors = {{
-                            "属于": "#9b59b6",           // 紫色
-                            "包含": "#ffb6c1",           // 淡粉
-                            "分类共现(跨点)": "#2ecc71", // 绿色
-                            "分类共现(点内)": "#3498db", // 蓝色
-                            "标签共现": "#f39c12"        // 橙色
-                        }};
-                        return edgeColors[edgeType] || "#9b59b6";
+                    return path + "Z";
+                }}
+
+                // 绘制树的边(帖子→维度→点→标签)
+                const treeLinks = postTreeGroup.selectAll(".tree-link")
+                    .data(postRoot.links())
+                    .enter()
+                    .append("path")
+                    .attr("class", "tree-link")
+                    .attr("d", d => {{
+                        const sourceY = d.source.data.isRoot ? d.source.y + cardHeight/2 : d.source.y;
+                        return `M${{d.source.x}},${{sourceY}}
+                                C${{d.source.x}},${{(sourceY + d.target.y) / 2}}
+                                 ${{d.target.x}},${{(sourceY + d.target.y) / 2}}
+                                 ${{d.target.x}},${{d.target.y}}`;
+                    }})
+                    .attr("fill", "none")
+                    .attr("stroke", "#9b59b6")
+                    .attr("stroke-width", 1.5)
+                    .attr("stroke-opacity", 0.6);
+
+                // 绘制树节点(非根节点)
+                const treeNodes = postTreeGroup.selectAll(".tree-node")
+                    .data(postRoot.descendants().filter(d => !d.data.isRoot))
+                    .enter()
+                    .append("g")
+                    .attr("class", "tree-node")
+                    .attr("transform", d => `translate(${{d.x}},${{d.y}})`);
+
+                // 节点形状
+                treeNodes.each(function(d) {{
+                    const node = d3.select(this);
+                    const fill = d.data.dimColor || "#888";
+
+                    if (d.data.nodeType === "点") {{
+                        node.append("path")
+                            .attr("d", hexagonPath(16))
+                            .attr("fill", fill)
+                            .attr("stroke", "#fff")
+                            .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 === "标签") {{
+                        node.append("circle")
+                            .attr("r", 12)
+                            .attr("fill", fill)
+                            .attr("stroke", "#fff")
+                            .attr("stroke-width", 2);
+                    }} else {{
+                        node.append("circle")
+                            .attr("r", 8)
+                            .attr("fill", fill)
+                            .attr("stroke", "#fff")
+                            .attr("stroke-width", 1);
                     }}
-                    return "#9b59b6";
-                }})
-                .attr("stroke-width", 1.5)
-                .attr("stroke-opacity", 0.6)
-                .attr("stroke-dasharray", d => {{
-                    // 相似匹配用虚线
-                    if (d.target.data.nodeType === "人设" && d.target.data.matchType === "相似") {{
-                        return "4,2";
+                }});
+
+                // 节点标签
+                treeNodes.append("text")
+                    .attr("dy", d => {{
+                        if (d.data.isDimension) return -22;
+                        if (d.data.nodeType === "点") return -24;
+                        if (d.data.nodeType === "标签") return -20;
+                        return -18;
+                    }})
+                    .attr("text-anchor", "middle")
+                    .attr("fill", d => d.data.dimColor || "#ccc")
+                    .attr("font-size", "11px")
+                    .attr("font-weight", d => d.data.isDimension ? "bold" : "normal")
+                    .text(d => d.data.name || "");
+
+                // 绘制根节点卡片
+                const postRootNode = postRoot;
+                const cardX = postRootNode.x - cardWidth/2;
+                const cardY = postRootNode.y - cardHeight/2;
+
+                const rootCard = postTreeGroup.append("foreignObject")
+                    .attr("x", cardX)
+                    .attr("y", cardY)
+                    .attr("width", cardWidth)
+                    .attr("height", cardHeight);
+
+                const images = postDetail.images || [];
+                const thumbnail = images.length > 0 ? images[0] : "";
+                const title = postDetail.title || "未知标题";
+                const likeCount = postDetail.like_count || 0;
+                const collectCount = postDetail.collect_count || 0;
+                const imageCount = images.length || 0;
+
+                const thumbnailHtml = thumbnail
+                    ? `<div class="post-card-thumbnail-wrapper" style="width:60px;height:60px;">
+                         <img class="post-card-thumbnail" style="width:60px;height:60px;" src="${{thumbnail}}" alt="封面" onerror="this.style.display='none'"/>
+                         ${{imageCount > 1 ? `<span class="post-card-image-count">${{imageCount}}图</span>` : ''}}
+                       </div>`
+                    : `<div class="post-card-thumbnail-placeholder" style="width:60px;height:60px;font-size:20px;">📝</div>`;
+
+                const cardHtml = `
+                    <div class="post-card" xmlns="http://www.w3.org/1999/xhtml" style="padding:10px;" onclick="showPostDetail(window.currentPostDetail)">
+                        <div class="post-card-header">
+                            ${{thumbnailHtml}}
+                            <div class="post-card-info">
+                                <div class="post-card-title" style="font-size:12px;-webkit-line-clamp:2;" title="${{title}}">${{title}}</div>
+                                <div class="post-card-stats" style="font-size:10px;">
+                                    <span>❤️ ${{likeCount}}</span>
+                                    <span>⭐ ${{collectCount}}</span>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                `;
+                rootCard.append("xhtml:div").html(cardHtml);
+
+                // 应用树的位置变换
+                postTreeGroup.attr("transform", `translate(${{postTreeOffsetX}}, ${{postTreeOffsetY}})`);
+
+                // 更新标签节点位置(加上偏移)- 每个ID可能有多个位置
+                Object.keys(tagNodePositions).forEach(id => {{
+                    tagNodePositions[id].forEach(pos => {{
+                        pos.x += postTreeOffsetX;
+                    }});
+                }});
+
+                // ===== 下半部分:三分图(标签 ↔ 人设_1层 ↔ 人设_2层)=====
+                // 复用圆形图的节点数据,但只取人设节点(去重)
+                const personaLayer1Nodes = nodes.filter(n => getNodeLayer(n) === 1);
+                const personaLayer2Nodes = nodes.filter(n => getNodeLayer(n) === 2);
+
+                // 过滤出层间边
+                const crossLayerLinks = links.filter(link => {{
+                    const srcLayer = getNodeLayer(link.source);
+                    const tgtLayer = getNodeLayer(link.target);
+                    return srcLayer !== tgtLayer;
+                }});
+
+                console.log("层间边数:", crossLayerLinks.length, "人设1层:", personaLayer1Nodes.length, "人设2层:", personaLayer2Nodes.length);
+
+                // 为人设节点计算X位置(根据连接的标签节点位置)
+                const personaNodePositions = {{}};
+
+                // 人设_1层:根据连接的帖子标签位置来定位
+                personaLayer1Nodes.forEach(n => {{
+                    // 找到连接到这个人设的帖子标签
+                    const connectedTags = crossLayerLinks
+                        .filter(l => l.target.id === n.id && getNodeLayer(l.source) === 0)
+                        .map(l => l.source.id);
+
+                    let avgX = 0;
+                    let count = 0;
+                    connectedTags.forEach(tagId => {{
+                        // 在树的标签节点中查找(每个tagId可能有多个位置)
+                        if (tagNodePositions[tagId]) {{
+                            tagNodePositions[tagId].forEach(pos => {{
+                                avgX += pos.x;
+                                count++;
+                            }});
+                        }}
+                    }});
+
+                    if (count > 0) {{
+                        personaNodePositions[n.id] = {{ x: avgX / count, y: targetPersonaY, node: n }};
+                    }} else {{
+                        // 没有连接的标签,放在中间
+                        const allTagX = Object.values(tagNodePositions).flatMap(arr => arr.map(t => t.x));
+                        const midX = allTagX.length > 0 ? (Math.min(...allTagX) + Math.max(...allTagX)) / 2 : circleRightEdge + treeGap;
+                        personaNodePositions[n.id] = {{ x: midX, y: targetPersonaY, node: n }};
                     }}
-                    return "none";
                 }});
 
-            // 只显示权重和相似度分数,直接在边的中间显示
+                // 人设_2层:根据连接的人设_1层位置来定位
+                personaLayer2Nodes.forEach(n => {{
+                    const connectedPersona1 = crossLayerLinks
+                        .filter(l => (l.target.id === n.id && getNodeLayer(l.source) === 1) ||
+                                     (l.source.id === n.id && getNodeLayer(l.target) === 1))
+                        .map(l => l.source.id === n.id ? l.target.id : l.source.id);
+
+                    let avgX = 0;
+                    let count = 0;
+                    connectedPersona1.forEach(pId => {{
+                        if (personaNodePositions[pId]) {{
+                            avgX += personaNodePositions[pId].x;
+                            count++;
+                        }}
+                    }});
 
-            // 在点->标签的边上显示权重(居中)
-            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", "#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;
-                let path = "";
-                for (let i = 0; i < 6; i++) {{
-                    const angle = a * i - Math.PI / 2;
-                    const x = r * Math.cos(angle);
-                    const y = r * Math.sin(angle);
-                    path += (i === 0 ? "M" : "L") + x + "," + y;
-                }}
-                return path + "Z";
-            }}
+                    if (count > 0) {{
+                        personaNodePositions[n.id] = {{ x: avgX / count, y: targetExpandedY, node: n }};
+                    }} else {{
+                        const allP1X = Object.values(personaNodePositions).filter(p => p.y === targetPersonaY).map(p => p.x);
+                        const midX = allP1X.length > 0 ? (Math.min(...allP1X) + Math.max(...allP1X)) / 2 : circleRightEdge + treeGap;
+                        personaNodePositions[n.id] = {{ x: midX, y: targetExpandedY, node: n }};
+                    }}
+                }});
 
-            // 绘制帖子树节点(非根节点)
-            const postTreeNodes = postTreeGroup.selectAll(".post-tree-node")
-                .data(postRoot.descendants().filter(d => !d.data.isRoot))
-                .enter()
-                .append("g")
-                .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(16))
-                        .attr("fill", fill)
-                        .attr("stroke", "#fff")
-                        .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 allGraphNodePositions = {{ ...tagNodePositions }};
+                Object.keys(personaNodePositions).forEach(id => {{
+                    allGraphNodePositions[id] = personaNodePositions[id];
+                }});
+
+                // 边颜色配置
+                const graphEdgeColors = {{
+                    "匹配_相同": "#e94560",
+                    "匹配_相似": "#e94560",
+                    "属于": "#9b59b6",
+                    "包含": "#ffb6c1",
+                    "分类共现(跨点)": "#2ecc71",
+                    "分类共现(点内)": "#3498db",
+                    "标签共现": "#f39c12"
+                }};
+
+                // 展开边数据:如果标签有多个位置,为每个位置创建一条边
+                const expandedLinks = [];
+                crossLayerLinks.forEach(link => {{
+                    const srcId = link.source.id;
+                    const tgtId = link.target.id;
+                    const srcLayer = getNodeLayer(link.source);
+                    const tgtLayer = getNodeLayer(link.target);
+
+                    // 源是层0(帖子标签),可能有多个位置
+                    if (srcLayer === 0 && tagNodePositions[srcId]) {{
+                        tagNodePositions[srcId].forEach((srcPos, idx) => {{
+                            const tgtPos = personaNodePositions[tgtId];
+                            if (tgtPos) {{
+                                expandedLinks.push({{
+                                    ...link,
+                                    srcPos: srcPos,
+                                    tgtPos: tgtPos,
+                                    key: `${{srcId}}_${{idx}}_${{tgtId}}`
+                                }});
+                            }}
+                        }});
+                    }}
+                    // 目标是层0(帖子标签),可能有多个位置
+                    else if (tgtLayer === 0 && tagNodePositions[tgtId]) {{
+                        tagNodePositions[tgtId].forEach((tgtPos, idx) => {{
+                            const srcPos = personaNodePositions[srcId];
+                            if (srcPos) {{
+                                expandedLinks.push({{
+                                    ...link,
+                                    srcPos: srcPos,
+                                    tgtPos: tgtPos,
+                                    key: `${{srcId}}_${{tgtId}}_${{idx}}`
+                                }});
+                            }}
+                        }});
+                    }}
+                    // 两端都不是层0,正常处理
+                    else {{
+                        const srcPos = personaNodePositions[srcId];
+                        const tgtPos = personaNodePositions[tgtId];
+                        if (srcPos && tgtPos) {{
+                            expandedLinks.push({{
+                                ...link,
+                                srcPos: srcPos,
+                                tgtPos: tgtPos,
+                                key: `${{srcId}}_${{tgtId}}`
+                            }});
+                        }}
+                    }}
+                }});
+
+                console.log("展开后边数:", expandedLinks.length);
+
+                // 绘制层间边(标签→人设、人设→人设扩展)
+                const graphLinksGroup = g.append("g").attr("class", "graph-links");
+                const graphLinks = graphLinksGroup.selectAll(".graph-link")
+                    .data(expandedLinks)
+                    .enter()
+                    .append("g")
+                    .attr("class", "graph-link-group");
+
+                graphLinks.append("path")
+                    .attr("class", "graph-link")
+                    .attr("d", d => {{
+                        const srcPos = d.srcPos;
+                        const tgtPos = d.tgtPos;
+                        if (!srcPos || !tgtPos) return "";
+                        return `M${{srcPos.x}},${{srcPos.y}} C${{srcPos.x}},${{(srcPos.y + tgtPos.y) / 2}} ${{tgtPos.x}},${{(srcPos.y + tgtPos.y) / 2}} ${{tgtPos.x}},${{tgtPos.y}}`;
+                    }})
+                    .attr("fill", "none")
+                    .attr("stroke", d => graphEdgeColors[d.type] || "#9b59b6")
+                    .attr("stroke-width", 1.5)
+                    .attr("stroke-opacity", 0.6)
+                    .attr("stroke-dasharray", d => d.type === "匹配_相似" ? "4,2" : "none");
+
+                // 在匹配边上显示相似度
+                graphLinks.filter(d => d.type && d.type.startsWith("匹配_") && d.similarity > 0)
+                    .append("text")
+                    .attr("x", d => (d.srcPos.x + d.tgtPos.x) / 2)
+                    .attr("y", d => (d.srcPos.y + d.tgtPos.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.similarity ? d.similarity.toFixed(2) : "");
+
+                // 绘制人设节点(去重后的)
+                const allPersonaNodes = [...personaLayer1Nodes, ...personaLayer2Nodes];
+                const graphNodesGroup = g.append("g").attr("class", "graph-nodes");
+                const graphNodes = graphNodesGroup.selectAll(".graph-node")
+                    .data(allPersonaNodes.filter(n => personaNodePositions[n.id]))
+                    .enter()
+                    .append("g")
+                    .attr("class", "graph-node")
+                    .attr("transform", d => {{
+                        const pos = personaNodePositions[d.id];
+                        return `translate(${{pos.x}},${{pos.y}})`;
+                    }})
+                    .style("cursor", "pointer");
+
+                // 人设节点形状
+                graphNodes.each(function(d) {{
+                    const node = d3.select(this);
+                    const fill = levelColors[d.节点层级] || "#888";
                     const size = 10;
-                    if (d.data.originalType === "分类") {{
-                        // 分类用方形
+
+                    if (d.节点类型 === "分类") {{
                         node.append("rect")
                             .attr("width", size * 2)
                             .attr("height", size * 2)
@@ -2247,86 +2436,39 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
                             .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", 8)
-                        .attr("fill", fill)
-                        .attr("stroke", "#fff")
-                        .attr("stroke-width", 1);
-                }}
-            }});
+                }});
 
-            // 节点标签(统一放在节点上方)
-            postTreeNodes.append("text")
-                .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", "11px")
-                .attr("font-weight", d => d.data.isDimension ? "bold" : "normal")
-                .text(d => d.data.name || "");
-
-            // 绘制根节点卡片(帖子详情卡片)
-            const postRootNode = postRoot;
-            const cardX = postRootNode.x - cardWidth/2;
-            const cardY = postRootNode.y - cardHeight/2;
-
-            const rootCard = postTreeGroup.append("foreignObject")
-                .attr("x", cardX)
-                .attr("y", cardY)
-                .attr("width", cardWidth)
-                .attr("height", cardHeight);
-
-            const images = postDetail.images || [];
-            const thumbnail = images.length > 0 ? images[0] : "";
-            const title = postDetail.title || "未知标题";
-            const likeCount = postDetail.like_count || 0;
-            const collectCount = postDetail.collect_count || 0;
-            const imageCount = images.length || 0;
-
-            const thumbnailHtml = thumbnail
-                ? `<div class="post-card-thumbnail-wrapper" style="width:60px;height:60px;">
-                     <img class="post-card-thumbnail" style="width:60px;height:60px;" src="${{thumbnail}}" alt="封面" onerror="this.style.display='none'"/>
-                     ${{imageCount > 1 ? `<span class="post-card-image-count">${{imageCount}}图</span>` : ''}}
-                   </div>`
-                : `<div class="post-card-thumbnail-placeholder" style="width:60px;height:60px;font-size:20px;">📝</div>`;
-
-            const cardHtml = `
-                <div class="post-card" xmlns="http://www.w3.org/1999/xhtml" style="padding:10px;" onclick="showPostDetail(window.currentPostDetail)">
-                    <div class="post-card-header">
-                        ${{thumbnailHtml}}
-                        <div class="post-card-info">
-                            <div class="post-card-title" style="font-size:12px;-webkit-line-clamp:2;" title="${{title}}">${{title}}</div>
-                            <div class="post-card-stats" style="font-size:10px;">
-                                <span>❤️ ${{likeCount}}</span>
-                                <span>⭐ ${{collectCount}}</span>
-                            </div>
-                        </div>
-                    </div>
-                </div>
-            `;
-            rootCard.append("xhtml:div").html(cardHtml);
+                // 人设节点标签
+                graphNodes.append("text")
+                    .attr("dy", -18)
+                    .attr("text-anchor", "middle")
+                    .attr("fill", d => levelColors[d.节点层级] || "#ccc")
+                    .attr("font-size", "11px")
+                    .text(d => d.节点名称 || d.name || "");
 
-            postTreeActualHeight = treeMaxY - treeMinY;
+                // 点击联动
+                graphNodes.on("click", function(event, d) {{
+                    event.stopPropagation();
+                    nodeElements.classed("highlighted", n => n.id === d.id);
+                    nodeElements.classed("dimmed", n => n.id !== d.id);
+                }});
+
+                // 存储引用
+                window.graphNodesRef = graphNodes;
+                window.allGraphNodePositions = allGraphNodePositions;
 
-            // 应用帖子树的位置变换(放在圆的右侧)
-            postTreeGroup.attr("transform", `translate(${{postTreeOffsetX}}, ${{postTreeOffsetY}})`);
+                postTreeActualHeight = targetExpandedY - (targetTagY - halfGap * 3) + 100;
             }} // end if (postTree && postTree.root)
 
+            // 计算高度
+            const tripartiteHeight = postTreeActualHeight;
+
             // 圆的Y位置不再受帖子树高度影响,使用固定偏移
             const circleYOffset = 40;
 
@@ -2335,10 +2477,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
             layerCenterY[1] += circleYOffset;
             layerCenterY[2] += circleYOffset;
 
-            // 更新SVG高度(取圆和帖子树的最大高度)
+            // 更新SVG高度(取圆和三分图的最大高度)
             const minHeight = Math.max(
                 layerCenterY[2] + layerRadius[2] + 50,
-                postTreeActualHeight + 150
+                tripartiteHeight + 150
             );
             svg.attr("height", Math.max(height, minHeight));