|
@@ -1996,28 +1996,31 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
highlightEdge(d, linkIndex);
|
|
highlightEdge(d, linkIndex);
|
|
|
showEdgeInfo(d);
|
|
showEdgeInfo(d);
|
|
|
|
|
|
|
|
- // 2. 确定要在人设树和关系图中展示的人设边
|
|
|
|
|
|
|
+ // 2. 确定要在人设树和关系图中展示的人设节点路径
|
|
|
const edgeDetail = d.边详情 || {{}};
|
|
const edgeDetail = d.边详情 || {{}};
|
|
|
const isMirrorEdge = d.type.startsWith("镜像_") || d.type.startsWith("二阶_");
|
|
const isMirrorEdge = d.type.startsWith("镜像_") || d.type.startsWith("二阶_");
|
|
|
|
|
|
|
|
- let personaSrcId, personaTgtId, edgeType;
|
|
|
|
|
|
|
+ let personaPathNodes = [];
|
|
|
|
|
+ let edgeType;
|
|
|
|
|
|
|
|
- if (isMirrorEdge && edgeDetail.源人设节点 && edgeDetail.目标人设节点) {{
|
|
|
|
|
- // 镜像边:使用对应的人设节点
|
|
|
|
|
- personaSrcId = edgeDetail.源人设节点;
|
|
|
|
|
- personaTgtId = edgeDetail.目标人设节点;
|
|
|
|
|
|
|
+ if (isMirrorEdge && edgeDetail.路径节点) {{
|
|
|
|
|
+ // 镜像边/二阶边:从路径节点中提取人设节点(不以帖子_开头的)
|
|
|
|
|
+ personaPathNodes = edgeDetail.路径节点.filter(id => !id.startsWith("帖子_"));
|
|
|
edgeType = edgeDetail.原始边类型 || d.type.replace("镜像_", "").replace("二阶_", "");
|
|
edgeType = edgeDetail.原始边类型 || d.type.replace("镜像_", "").replace("二阶_", "");
|
|
|
}} else {{
|
|
}} else {{
|
|
|
// 普通边:使用边的两端节点
|
|
// 普通边:使用边的两端节点
|
|
|
const sourceNode = typeof d.source === "object" ? d.source : nodes.find(n => n.id === d.source);
|
|
const sourceNode = typeof d.source === "object" ? d.source : nodes.find(n => n.id === d.source);
|
|
|
const targetNode = typeof d.target === "object" ? d.target : nodes.find(n => n.id === d.target);
|
|
const targetNode = typeof d.target === "object" ? d.target : nodes.find(n => n.id === d.target);
|
|
|
- personaSrcId = sourceNode ? sourceNode.id : d.source;
|
|
|
|
|
- personaTgtId = targetNode ? targetNode.id : d.target;
|
|
|
|
|
|
|
+ const srcId = sourceNode ? sourceNode.id : d.source;
|
|
|
|
|
+ const tgtId = targetNode ? targetNode.id : d.target;
|
|
|
|
|
+ // 只有人设节点才加入路径
|
|
|
|
|
+ if (!srcId.startsWith("帖子_")) personaPathNodes.push(srcId);
|
|
|
|
|
+ if (!tgtId.startsWith("帖子_")) personaPathNodes.push(tgtId);
|
|
|
edgeType = d.type;
|
|
edgeType = d.type;
|
|
|
}}
|
|
}}
|
|
|
|
|
|
|
|
// 3. 同步人设树高亮和关系图展示
|
|
// 3. 同步人设树高亮和关系图展示
|
|
|
- syncTreeAndRelationGraph(personaSrcId, personaTgtId, edgeType);
|
|
|
|
|
|
|
+ syncTreeAndRelationGraph(personaPathNodes, edgeType);
|
|
|
}})
|
|
}})
|
|
|
.on("mouseover", function(event, d) {{
|
|
.on("mouseover", function(event, d) {{
|
|
|
d3.select(this.parentNode).select(".link")
|
|
d3.select(this.parentNode).select(".link")
|
|
@@ -2776,7 +2779,9 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
}}
|
|
}}
|
|
|
|
|
|
|
|
// 同步人设树高亮和关系图展示(职责单一:不处理力导向图)
|
|
// 同步人设树高亮和关系图展示(职责单一:不处理力导向图)
|
|
|
- function syncTreeAndRelationGraph(srcNodeId, tgtNodeId, edgeType) {{
|
|
|
|
|
|
|
+ function syncTreeAndRelationGraph(pathNodeIds, edgeType) {{
|
|
|
|
|
+ if (!pathNodeIds || pathNodeIds.length === 0) return;
|
|
|
|
|
+
|
|
|
// 动态获取树相关元素
|
|
// 动态获取树相关元素
|
|
|
const treeGroup = d3.select(".persona-tree");
|
|
const treeGroup = d3.select(".persona-tree");
|
|
|
if (treeGroup.empty()) return;
|
|
if (treeGroup.empty()) return;
|
|
@@ -2804,64 +2809,51 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
|
|
if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
- const sourceTreeNode = treeNodeById[srcNodeId];
|
|
|
|
|
- const targetTreeNode = treeNodeById[tgtNodeId];
|
|
|
|
|
|
|
+ // 路径节点ID集合
|
|
|
|
|
+ const pathNodeIdSet = new Set(pathNodeIds);
|
|
|
|
|
|
|
|
- // 高亮树边(不变粗)
|
|
|
|
|
|
|
+ // 高亮树边(路径上相邻节点之间的边)
|
|
|
treeEdges.attr("stroke-opacity", function(e) {{
|
|
treeEdges.attr("stroke-opacity", function(e) {{
|
|
|
- const isThisEdge = (e.源节点ID === srcNodeId && e.目标节点ID === tgtNodeId) ||
|
|
|
|
|
- (e.源节点ID === tgtNodeId && e.目标节点ID === srcNodeId);
|
|
|
|
|
- return isThisEdge ? 1 : 0.1;
|
|
|
|
|
|
|
+ const srcInPath = pathNodeIdSet.has(e.源节点ID);
|
|
|
|
|
+ const tgtInPath = pathNodeIdSet.has(e.目标节点ID);
|
|
|
|
|
+ return (srcInPath && tgtInPath) ? 1 : 0.1;
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
- // 高亮相关节点(使用节点ID集合判断)
|
|
|
|
|
- const connectedNodeIds = new Set([srcNodeId, tgtNodeId]);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ // 高亮路径上的节点
|
|
|
treeGroup.selectAll(".tree-node").each(function(n) {{
|
|
treeGroup.selectAll(".tree-node").each(function(n) {{
|
|
|
const nodeId = n.data.节点ID;
|
|
const nodeId = n.data.节点ID;
|
|
|
- const isConnected = connectedNodeIds.has(nodeId);
|
|
|
|
|
|
|
+ const isInPath = pathNodeIdSet.has(nodeId);
|
|
|
d3.select(this).select(".tree-shape")
|
|
d3.select(this).select(".tree-shape")
|
|
|
- .attr("fill", isConnected ? getTreeNodeColor(n) : "#555")
|
|
|
|
|
|
|
+ .attr("fill", isInPath ? getTreeNodeColor(n) : "#555")
|
|
|
.attr("opacity", 1);
|
|
.attr("opacity", 1);
|
|
|
d3.select(this).select("text")
|
|
d3.select(this).select("text")
|
|
|
- .attr("fill", isConnected ?
|
|
|
|
|
|
|
+ .attr("fill", isInPath ?
|
|
|
((n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb") : "#555")
|
|
((n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb") : "#555")
|
|
|
.attr("opacity", 1);
|
|
.attr("opacity", 1);
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
- // 显示边详情
|
|
|
|
|
|
|
+ // 显示路径详情
|
|
|
const panel = document.getElementById("detailPanel");
|
|
const panel = document.getElementById("detailPanel");
|
|
|
panel.classList.add("active");
|
|
panel.classList.add("active");
|
|
|
- document.getElementById("detailTitle").textContent = "🔗 边详情";
|
|
|
|
|
-
|
|
|
|
|
- // 从 personaTreeData 中获取节点数据(即使不在可视化树中)
|
|
|
|
|
- const srcNodeData = personaTreeData.nodes.find(n => n.节点ID === srcNodeId);
|
|
|
|
|
- const tgtNodeData = personaTreeData.nodes.find(n => n.节点ID === tgtNodeId);
|
|
|
|
|
-
|
|
|
|
|
- // 构造显示用的节点名称
|
|
|
|
|
- const srcName = sourceTreeNode ? (sourceTreeNode.data.节点名称 || sourceTreeNode.data.name) :
|
|
|
|
|
- (srcNodeData?.节点名称 || srcNodeId);
|
|
|
|
|
- const tgtName = targetTreeNode ? (targetTreeNode.data.节点名称 || targetTreeNode.data.name) :
|
|
|
|
|
- (tgtNodeData?.节点名称 || tgtNodeId);
|
|
|
|
|
|
|
+ document.getElementById("detailTitle").textContent = "🔗 路径详情";
|
|
|
|
|
+
|
|
|
|
|
+ // 获取路径上的节点名称
|
|
|
|
|
+ const pathNodeNames = pathNodeIds.map(id => {{
|
|
|
|
|
+ const treeNode = treeNodeById[id];
|
|
|
|
|
+ if (treeNode) return treeNode.data.节点名称 || treeNode.data.name;
|
|
|
|
|
+ const nodeData = personaTreeData.nodes.find(n => n.节点ID === id);
|
|
|
|
|
+ return nodeData?.节点名称 || id;
|
|
|
|
|
+ }});
|
|
|
|
|
|
|
|
let html = `
|
|
let html = `
|
|
|
<p><span class="label">边类型:</span> <strong>${{edgeType}}</strong></p>
|
|
<p><span class="label">边类型:</span> <strong>${{edgeType}}</strong></p>
|
|
|
- <p><span class="label">源节点:</span> ${{srcName}}</p>
|
|
|
|
|
- <p><span class="label">目标节点:</span> ${{tgtName}}</p>
|
|
|
|
|
|
|
+ <p><span class="label">路径节点:</span> ${{pathNodeNames.join(" → ")}}</p>
|
|
|
|
|
+ <p><span class="label">节点数:</span> ${{pathNodeIds.length}}</p>
|
|
|
`;
|
|
`;
|
|
|
document.getElementById("detailContent").innerHTML = html;
|
|
document.getElementById("detailContent").innerHTML = html;
|
|
|
|
|
|
|
|
- // 在关系图中展示这条边和两个节点
|
|
|
|
|
- // 构造节点对象(优先使用树节点,否则使用 personaTreeData 中的数据)
|
|
|
|
|
- const srcForEgo = sourceTreeNode || (srcNodeData ? {{ data: srcNodeData }} : null);
|
|
|
|
|
- const tgtForEgo = targetTreeNode || (tgtNodeData ? {{ data: tgtNodeData }} : null);
|
|
|
|
|
-
|
|
|
|
|
- const edgeData = {{
|
|
|
|
|
- 边类型: edgeType,
|
|
|
|
|
- 源节点ID: srcNodeId,
|
|
|
|
|
- 目标节点ID: tgtNodeId
|
|
|
|
|
- }};
|
|
|
|
|
- renderEgoGraphEdge(edgeData, srcForEgo, tgtForEgo);
|
|
|
|
|
|
|
+ // 在关系图中展示路径上的所有节点和边
|
|
|
|
|
+ renderEgoGraphPath(pathNodeIds, edgeType);
|
|
|
}}
|
|
}}
|
|
|
|
|
|
|
|
// 关系子图(Ego Graph)- 在画布第四层显示
|
|
// 关系子图(Ego Graph)- 在画布第四层显示
|
|
@@ -3434,6 +3426,165 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.style("visibility", "visible");
|
|
.style("visibility", "visible");
|
|
|
}}
|
|
}}
|
|
|
|
|
|
|
|
|
|
+ // 渲染路径上的所有节点和边(点击镜像边/二阶边时调用)
|
|
|
|
|
+ function renderEgoGraphPath(pathNodeIds, edgeType) {{
|
|
|
|
|
+ if (!pathNodeIds || pathNodeIds.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 显示关系图容器
|
|
|
|
|
+ document.getElementById("ego-container").style.display = "block";
|
|
|
|
|
+
|
|
|
|
|
+ const egoGroup = d3.select(".ego-graph-content");
|
|
|
|
|
+ if (egoGroup.empty()) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 停止之前的模拟
|
|
|
|
|
+ if (currentEgoSimulation) {{
|
|
|
|
|
+ currentEgoSimulation.stop();
|
|
|
|
|
+ currentEgoSimulation = null;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 清除旧内容
|
|
|
|
|
+ egoGroup.selectAll("*").remove();
|
|
|
|
|
+
|
|
|
|
|
+ // 边类型颜色
|
|
|
|
|
+ const edgeColors = {{
|
|
|
|
|
+ "属于": "#9b59b6",
|
|
|
|
|
+ "包含": "#ffb6c1",
|
|
|
|
|
+ "分类共现(跨点)": "#2ecc71",
|
|
|
|
|
+ "分类共现(点内)": "#3498db",
|
|
|
|
|
+ "标签共现": "#f39c12"
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ // 维度颜色
|
|
|
|
|
+ const dimColors = {{
|
|
|
|
|
+ "灵感点": "#f39c12",
|
|
|
|
|
+ "目的点": "#3498db",
|
|
|
|
|
+ "关键点": "#9b59b6"
|
|
|
|
|
+ }};
|
|
|
|
|
+
|
|
|
|
|
+ // 获取节点颜色
|
|
|
|
|
+ function getNodeColor(nodeData) {{
|
|
|
|
|
+ const level = nodeData.节点层级 || "";
|
|
|
|
|
+ if (level.includes("灵感点")) return dimColors["灵感点"];
|
|
|
|
|
+ if (level.includes("目的点")) return dimColors["目的点"];
|
|
|
|
|
+ if (level.includes("关键点")) return dimColors["关键点"];
|
|
|
|
|
+ return "#888";
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 获取路径节点数据
|
|
|
|
|
+ const pathNodes = pathNodeIds.map(id => {{
|
|
|
|
|
+ const nodeData = personaTreeData.nodes.find(n => n.节点ID === id);
|
|
|
|
|
+ return nodeData || {{ 节点ID: id, 节点名称: id, 节点类型: "标签" }};
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 找出路径上相邻节点之间的边
|
|
|
|
|
+ const pathEdges = [];
|
|
|
|
|
+ for (let i = 0; i < pathNodeIds.length - 1; i++) {{
|
|
|
|
|
+ const srcId = pathNodeIds[i];
|
|
|
|
|
+ const tgtId = pathNodeIds[i + 1];
|
|
|
|
|
+ // 在 personaTreeData.edges 中查找
|
|
|
|
|
+ const edge = personaTreeData.edges.find(e =>
|
|
|
|
|
+ (e.源节点ID === srcId && e.目标节点ID === tgtId) ||
|
|
|
|
|
+ (e.源节点ID === tgtId && e.目标节点ID === srcId)
|
|
|
|
|
+ );
|
|
|
|
|
+ pathEdges.push(edge || {{ 边类型: edgeType, 源节点ID: srcId, 目标节点ID: tgtId }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 更新顶部标题
|
|
|
|
|
+ document.getElementById("ego-node-name").textContent = edgeType;
|
|
|
|
|
+ document.getElementById("ego-stats").textContent = `${{pathNodes.length}}节点 ${{pathEdges.length}}边`;
|
|
|
|
|
+
|
|
|
|
|
+ // 水平线性布局
|
|
|
|
|
+ const nodeSpacing = 80;
|
|
|
|
|
+ const totalWidth = (pathNodes.length - 1) * nodeSpacing;
|
|
|
|
|
+ const startX = -totalWidth / 2;
|
|
|
|
|
+
|
|
|
|
|
+ // 绘制边
|
|
|
|
|
+ pathEdges.forEach((edge, i) => {{
|
|
|
|
|
+ const x1 = startX + i * nodeSpacing;
|
|
|
|
|
+ const x2 = startX + (i + 1) * nodeSpacing;
|
|
|
|
|
+
|
|
|
|
|
+ // 边的可点击区域(更宽的透明线)
|
|
|
|
|
+ egoGroup.append("line")
|
|
|
|
|
+ .attr("class", "ego-edge-hitarea")
|
|
|
|
|
+ .attr("x1", x1)
|
|
|
|
|
+ .attr("y1", 0)
|
|
|
|
|
+ .attr("x2", x2)
|
|
|
|
|
+ .attr("y2", 0)
|
|
|
|
|
+ .attr("stroke", "transparent")
|
|
|
|
|
+ .attr("stroke-width", 15)
|
|
|
|
|
+ .style("cursor", "pointer")
|
|
|
|
|
+ .on("click", (event) => {{
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ handleEdgeClick(edge.源节点ID, edge.目标节点ID, edge.边类型);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 可见的边
|
|
|
|
|
+ egoGroup.append("line")
|
|
|
|
|
+ .attr("class", "ego-edge")
|
|
|
|
|
+ .attr("x1", x1)
|
|
|
|
|
+ .attr("y1", 0)
|
|
|
|
|
+ .attr("x2", x2)
|
|
|
|
|
+ .attr("y2", 0)
|
|
|
|
|
+ .attr("stroke", edgeColors[edge.边类型] || "#666")
|
|
|
|
|
+ .attr("stroke-width", 3)
|
|
|
|
|
+ .attr("stroke-opacity", 0.8)
|
|
|
|
|
+ .style("pointer-events", "none");
|
|
|
|
|
+
|
|
|
|
|
+ // 边类型标签
|
|
|
|
|
+ egoGroup.append("text")
|
|
|
|
|
+ .attr("x", (x1 + x2) / 2)
|
|
|
|
|
+ .attr("y", 20)
|
|
|
|
|
+ .attr("text-anchor", "middle")
|
|
|
|
|
+ .attr("fill", "#999")
|
|
|
|
|
+ .attr("font-size", "9px")
|
|
|
|
|
+ .style("pointer-events", "none")
|
|
|
|
|
+ .text(edge.边类型);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 绘制节点
|
|
|
|
|
+ pathNodes.forEach((nodeData, i) => {{
|
|
|
|
|
+ const x = startX + i * nodeSpacing;
|
|
|
|
|
+ const nodeGroup = egoGroup.append("g")
|
|
|
|
|
+ .attr("class", "ego-node")
|
|
|
|
|
+ .attr("transform", `translate(${{x}}, 0)`)
|
|
|
|
|
+ .style("cursor", "pointer")
|
|
|
|
|
+ .on("click", (event) => {{
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ handleNodeClick(nodeData.节点ID, nodeData.节点名称 || nodeData.name);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ const nodeSize = 15;
|
|
|
|
|
+ const nodeColor = getNodeColor(nodeData);
|
|
|
|
|
+
|
|
|
|
|
+ if (nodeData.节点类型 === "分类") {{
|
|
|
|
|
+ nodeGroup.append("rect")
|
|
|
|
|
+ .attr("width", nodeSize * 2)
|
|
|
|
|
+ .attr("height", nodeSize * 2)
|
|
|
|
|
+ .attr("x", -nodeSize)
|
|
|
|
|
+ .attr("y", -nodeSize)
|
|
|
|
|
+ .attr("fill", nodeColor)
|
|
|
|
|
+ .attr("stroke", "rgba(255,255,255,0.5)")
|
|
|
|
|
+ .attr("stroke-width", 2)
|
|
|
|
|
+ .attr("rx", 2);
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ nodeGroup.append("circle")
|
|
|
|
|
+ .attr("r", nodeSize)
|
|
|
|
|
+ .attr("fill", nodeColor)
|
|
|
|
|
+ .attr("stroke", "rgba(255,255,255,0.5)")
|
|
|
|
|
+ .attr("stroke-width", 2);
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 节点名称
|
|
|
|
|
+ nodeGroup.append("text")
|
|
|
|
|
+ .attr("dy", -nodeSize - 8)
|
|
|
|
|
+ .attr("text-anchor", "middle")
|
|
|
|
|
+ .attr("fill", "#fff")
|
|
|
|
|
+ .attr("font-size", "10px")
|
|
|
|
|
+ .style("pointer-events", "none")
|
|
|
|
|
+ .text(nodeData.节点名称 || nodeData.name || nodeData.节点ID);
|
|
|
|
|
+ }});
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
// 渲染单条边和两个节点(点击树边时调用)
|
|
// 渲染单条边和两个节点(点击树边时调用)
|
|
|
function renderEgoGraphEdge(edgeData, sourceNode, targetNode) {{
|
|
function renderEgoGraphEdge(edgeData, sourceNode, targetNode) {{
|
|
|
// 显示关系图容器
|
|
// 显示关系图容器
|