|
|
@@ -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));
|
|
|
|