|
|
@@ -2187,11 +2187,97 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.style("cursor", "pointer")
|
|
|
.on("click", (event, d) => {{
|
|
|
event.stopPropagation();
|
|
|
- // 复用共享的节点点击处理函数
|
|
|
- handleNodeClick(d.data.id || d.data.节点ID, d.data.name || d.data.节点名称);
|
|
|
+ const nodeId = d.data.id || d.data.节点ID;
|
|
|
+ const nodeName = d.data.name || d.data.节点名称;
|
|
|
+ // 帖子标签节点使用专门的处理函数
|
|
|
+ if (d.data.nodeType === "标签") {{
|
|
|
+ handlePostTagClick(nodeId, nodeName);
|
|
|
+ }} else {{
|
|
|
+ handleNodeClick(nodeId, nodeName);
|
|
|
+ }}
|
|
|
}});
|
|
|
|
|
|
// 节点形状
|
|
|
+ // 帖子标签节点点击处理函数(激活所有直连的层内边,效果等价于点击N条层内边的并集)
|
|
|
+ function handlePostTagClick(tagNodeId, tagNodeName) {{
|
|
|
+ if (!tagNodeId) return;
|
|
|
+
|
|
|
+ // 层内边类型
|
|
|
+ const intraLayerEdgeTypes = [
|
|
|
+ "标签共现", "分类共现(跨点)", "分类共现(点内)",
|
|
|
+ "镜像_标签共现", "镜像_分类共现(跨点)", "镜像_分类共现(点内)",
|
|
|
+ "二阶_标签共现", "二阶_分类共现(跨点)", "二阶_分类共现(点内)"
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 找到所有与该标签直连的层内边及其索引
|
|
|
+ const connectedIntraEdges = [];
|
|
|
+ links.forEach((link, index) => {{
|
|
|
+ const srcId = typeof link.source === "object" ? link.source.id : link.source;
|
|
|
+ const tgtId = typeof link.target === "object" ? link.target.id : link.target;
|
|
|
+ if ((srcId === tagNodeId || tgtId === tagNodeId) && intraLayerEdgeTypes.includes(link.type)) {{
|
|
|
+ connectedIntraEdges.push({{ link, index }});
|
|
|
+ }}
|
|
|
+ }});
|
|
|
+
|
|
|
+ console.log("帖子标签直连的层内边数:", connectedIntraEdges.length);
|
|
|
+
|
|
|
+ if (connectedIntraEdges.length === 0) {{
|
|
|
+ // 没有层内边,回退到普通处理
|
|
|
+ handleNodeClick(tagNodeId, tagNodeName);
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ // 对每条边调用collectEdgeHighlightData,然后合并结果
|
|
|
+ const mergedNodeIds = new Set();
|
|
|
+ const mergedLinkIndices = new Set();
|
|
|
+ const mergedPathNodes = new Set();
|
|
|
+
|
|
|
+ connectedIntraEdges.forEach(({{ link, index }}) => {{
|
|
|
+ const {{ nodeIds, linkIndices, pathNodes }} = collectEdgeHighlightData(link, index);
|
|
|
+ nodeIds.forEach(id => mergedNodeIds.add(id));
|
|
|
+ linkIndices.forEach(i => mergedLinkIndices.add(i));
|
|
|
+ pathNodes.forEach(n => mergedPathNodes.add(n));
|
|
|
+ }});
|
|
|
+
|
|
|
+ // 复用applyEdgeHighlight进行高亮(处理左边圆和右边树)
|
|
|
+ applyEdgeHighlight(mergedNodeIds, mergedLinkIndices);
|
|
|
+
|
|
|
+ // 在人设树中高亮所有相关路径节点(过滤掉帖子节点)
|
|
|
+ const personaPathNodes = Array.from(mergedPathNodes).filter(id => !id.startsWith("帖子_"));
|
|
|
+ if (personaPathNodes.length > 0) {{
|
|
|
+ // 获取边类型统计作为显示
|
|
|
+ const edgeTypeCount = {{}};
|
|
|
+ connectedIntraEdges.forEach(e => {{
|
|
|
+ const t = e.link.type.replace("镜像_", "").replace("二阶_", "");
|
|
|
+ edgeTypeCount[t] = (edgeTypeCount[t] || 0) + 1;
|
|
|
+ }});
|
|
|
+ const typeStr = Object.entries(edgeTypeCount).map(([t, c]) => `${{t}}×${{c}}`).join(", ");
|
|
|
+ syncTreeAndRelationGraph(personaPathNodes, typeStr);
|
|
|
+ }}
|
|
|
+
|
|
|
+ // 显示节点详情
|
|
|
+ const panel = document.getElementById("detailPanel");
|
|
|
+ panel.classList.add("active");
|
|
|
+ document.getElementById("detailTitle").textContent = "📌 帖子标签";
|
|
|
+ let html = `
|
|
|
+ <p><span class="label">标签:</span> <strong>${{tagNodeName}}</strong></p>
|
|
|
+ <p><span class="label">层内边数:</span> ${{connectedIntraEdges.length}}</p>
|
|
|
+ <h4 style="margin-top:12px;color:#f39c12;font-size:12px;">🔗 层内关联</h4>
|
|
|
+ <div class="edge-list">
|
|
|
+ `;
|
|
|
+ // 按边类型分组统计
|
|
|
+ const edgeTypeCount = {{}};
|
|
|
+ connectedIntraEdges.forEach(e => {{
|
|
|
+ const t = e.link.type.replace("镜像_", "").replace("二阶_", "");
|
|
|
+ edgeTypeCount[t] = (edgeTypeCount[t] || 0) + 1;
|
|
|
+ }});
|
|
|
+ for (const [type, count] of Object.entries(edgeTypeCount)) {{
|
|
|
+ html += `<div class="edge-type-item"><span class="edge-type">${{type}}</span><span class="edge-count">${{count}}</span></div>`;
|
|
|
+ }}
|
|
|
+ html += `</div>`;
|
|
|
+ document.getElementById("detailContent").innerHTML = html;
|
|
|
+ }}
|
|
|
+
|
|
|
treeNodes.each(function(d) {{
|
|
|
const node = d3.select(this);
|
|
|
const fill = d.data.dimColor || "#888";
|
|
|
@@ -2505,7 +2591,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
allGraphNodePositions[id] = personaNodePositions[id];
|
|
|
}});
|
|
|
|
|
|
- // 边颜色配置
|
|
|
+ // 边颜色配置(包括镜像边和二阶边)
|
|
|
const graphEdgeColors = {{
|
|
|
"匹配_相同": "#e94560",
|
|
|
"匹配_相似": "#e94560",
|
|
|
@@ -2513,7 +2599,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
"包含": "#3498db",
|
|
|
"分类共现(跨点)": "#2ecc71",
|
|
|
"分类共现(点内)": "#2ecc71",
|
|
|
- "标签共现": "#f39c12"
|
|
|
+ "标签共现": "#f39c12",
|
|
|
+ // 镜像边(与原始边颜色相同)
|
|
|
+ "镜像_分类共现(跨点)": "#2ecc71",
|
|
|
+ "镜像_分类共现(点内)": "#2ecc71",
|
|
|
+ "镜像_标签共现": "#f39c12",
|
|
|
+ // 二阶边(与原始边颜色相同)
|
|
|
+ "二阶_分类共现(跨点)": "#2ecc71",
|
|
|
+ "二阶_分类共现(点内)": "#2ecc71",
|
|
|
+ "二阶_标签共现": "#f39c12"
|
|
|
}};
|
|
|
|
|
|
// 展开边数据:如果标签有多个位置,为每个位置创建一条边
|
|
|
@@ -2629,7 +2723,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
return `M${{src.x}},${{src.y}} Q${{midX}},${{midY}} ${{tgt.x}},${{tgt.y}}`;
|
|
|
}};
|
|
|
|
|
|
- // 可见的跨层边
|
|
|
+ // 可见的跨层边(不可交互)
|
|
|
graphLinks.append("path")
|
|
|
.attr("class", "graph-link")
|
|
|
.attr("d", graphLinkPath)
|
|
|
@@ -2637,61 +2731,10 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.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")
|
|
|
- .style("pointer-events", "none"); // 让事件穿透到热区
|
|
|
+ .attr("stroke-dasharray", d => d.type === "匹配_相似" ? "4,2" : "none");
|
|
|
|
|
|
- // 跨层边热区(方便点击)
|
|
|
- graphLinks.append("path")
|
|
|
- .attr("class", "graph-link-hitarea")
|
|
|
- .attr("d", graphLinkPath)
|
|
|
- .attr("fill", "none")
|
|
|
- .attr("stroke", "transparent")
|
|
|
- .attr("stroke-width", 15)
|
|
|
- .style("cursor", "pointer")
|
|
|
- .on("mouseover", function(event, d) {{
|
|
|
- // 高亮对应的可见边
|
|
|
- d3.select(this.parentNode).select(".graph-link")
|
|
|
- .attr("stroke-width", 3)
|
|
|
- .attr("stroke-opacity", 1);
|
|
|
- }})
|
|
|
- .on("mouseout", function(event, d) {{
|
|
|
- // 恢复边样式
|
|
|
- d3.select(this.parentNode).select(".graph-link")
|
|
|
- .attr("stroke-width", 1.5)
|
|
|
- .attr("stroke-opacity", 0.6);
|
|
|
- }})
|
|
|
- .on("click", function(event, d) {{
|
|
|
- event.stopPropagation();
|
|
|
- // 获取边两端节点ID
|
|
|
- const srcId = d.source ? (d.source.id || d.source) : null;
|
|
|
- const tgtId = d.target ? (d.target.id || d.target) : null;
|
|
|
- if (!srcId || !tgtId) return;
|
|
|
-
|
|
|
- // 在左边圆中找对应的节点,触发点击
|
|
|
- const leftNode = nodes.find(n => n.id === srcId || n.id === tgtId);
|
|
|
- if (leftNode) {{
|
|
|
- highlightNode(leftNode);
|
|
|
- showNodeInfo(leftNode);
|
|
|
- if (leftNode.source === "人设") {{
|
|
|
- handleNodeClick(leftNode.id, leftNode.节点名称);
|
|
|
- }} else {{
|
|
|
- // 帖子标签节点
|
|
|
- const highlightedNodeIds = new Set([srcId, tgtId]);
|
|
|
- const highlightedEdgeKeys = new Set([`${{srcId}}|${{tgtId}}`, `${{tgtId}}|${{srcId}}`]);
|
|
|
- g.selectAll(".link-group").each(function(link) {{
|
|
|
- const lSrcId = typeof link.source === "object" ? link.source.id : link.source;
|
|
|
- const lTgtId = typeof link.target === "object" ? link.target.id : link.target;
|
|
|
- if (highlightedNodeIds.has(lSrcId) || highlightedNodeIds.has(lTgtId)) {{
|
|
|
- highlightedNodeIds.add(lSrcId);
|
|
|
- highlightedNodeIds.add(lTgtId);
|
|
|
- highlightedEdgeKeys.add(`${{lSrcId}}|${{lTgtId}}`);
|
|
|
- highlightedEdgeKeys.add(`${{lTgtId}}|${{lSrcId}}`);
|
|
|
- }}
|
|
|
- }});
|
|
|
- highlightRightTree(highlightedNodeIds, highlightedEdgeKeys);
|
|
|
- }}
|
|
|
- }}
|
|
|
- }});
|
|
|
+ // 跨层边设为不可交互
|
|
|
+ graphLinks.style("pointer-events", "none");
|
|
|
|
|
|
// 在有分数的边上显示分数(统一逻辑)
|
|
|
graphLinks.filter(d => d.score !== null && d.score !== undefined)
|
|
|
@@ -2763,6 +2806,259 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
window.graphNodesRef = graphNodes;
|
|
|
window.allGraphNodePositions = allGraphNodePositions;
|
|
|
|
|
|
+ // ===== 层内边:同层节点之间的关系边 =====
|
|
|
+ // 筛选层内边(包括原始边、镜像边、二阶边)
|
|
|
+ const intraLayerEdgeTypes = [
|
|
|
+ "标签共现", "分类共现(跨点)", "分类共现(点内)",
|
|
|
+ "镜像_标签共现", "镜像_分类共现(跨点)", "镜像_分类共现(点内)",
|
|
|
+ "二阶_标签共现", "二阶_分类共现(跨点)", "二阶_分类共现(点内)"
|
|
|
+ ];
|
|
|
+ const intraLayerLinks = links.filter(link => {{
|
|
|
+ const srcLayer = getNodeLayer(link.source);
|
|
|
+ const tgtLayer = getNodeLayer(link.target);
|
|
|
+ // 同层且是关系类边
|
|
|
+ return srcLayer === tgtLayer && intraLayerEdgeTypes.includes(link.type);
|
|
|
+ }});
|
|
|
+
|
|
|
+ console.log("层内边数:", intraLayerLinks.length);
|
|
|
+
|
|
|
+ // 展开层内边数据(处理多位置标签)
|
|
|
+ const expandedIntraLinks = [];
|
|
|
+ intraLayerLinks.forEach(link => {{
|
|
|
+ const srcId = link.source.id;
|
|
|
+ const tgtId = link.target.id;
|
|
|
+ const srcLayer = getNodeLayer(link.source);
|
|
|
+
|
|
|
+ // 帖子标签层(层0)- 可能有多个位置
|
|
|
+ if (srcLayer === 0) {{
|
|
|
+ const srcPositions = tagNodePositions[srcId] || [];
|
|
|
+ const tgtPositions = tagNodePositions[tgtId] || [];
|
|
|
+ // 为每对位置创建一条边
|
|
|
+ srcPositions.forEach((srcPos, si) => {{
|
|
|
+ tgtPositions.forEach((tgtPos, ti) => {{
|
|
|
+ expandedIntraLinks.push({{
|
|
|
+ ...link,
|
|
|
+ srcNode: srcPos,
|
|
|
+ tgtNode: tgtPos,
|
|
|
+ key: `intra_${{srcId}}_${{si}}_${{tgtId}}_${{ti}}`
|
|
|
+ }});
|
|
|
+ }});
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+ // 人设层(层1、2)
|
|
|
+ else {{
|
|
|
+ const srcNode = personaNodePositions[srcId];
|
|
|
+ const tgtNode = personaNodePositions[tgtId];
|
|
|
+ if (srcNode && tgtNode) {{
|
|
|
+ expandedIntraLinks.push({{
|
|
|
+ ...link,
|
|
|
+ srcNode: srcNode,
|
|
|
+ tgtNode: tgtNode,
|
|
|
+ key: `intra_${{srcId}}_${{tgtId}}`
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+ }});
|
|
|
+
|
|
|
+ // 去重层内边
|
|
|
+ const seenIntraEdges = new Set();
|
|
|
+ const dedupedIntraLinks = expandedIntraLinks.filter(link => {{
|
|
|
+ const srcId = link.source.id || link.source;
|
|
|
+ const tgtId = link.target.id || link.target;
|
|
|
+ const edgeKey = [srcId, tgtId].sort().join("|") + "|" + link.type;
|
|
|
+ if (seenIntraEdges.has(edgeKey)) return false;
|
|
|
+ seenIntraEdges.add(edgeKey);
|
|
|
+ return true;
|
|
|
+ }});
|
|
|
+
|
|
|
+ console.log("去重后层内边数:", dedupedIntraLinks.length);
|
|
|
+
|
|
|
+ // 为层内边计算偏移(避免重叠)
|
|
|
+ // 按源节点分组,给每条边分配不同的弧度
|
|
|
+ const intraEdgesBySource = {{}};
|
|
|
+ dedupedIntraLinks.forEach((link, idx) => {{
|
|
|
+ const srcId = link.source.id || link.source;
|
|
|
+ if (!intraEdgesBySource[srcId]) intraEdgesBySource[srcId] = [];
|
|
|
+ intraEdgesBySource[srcId].push({{ link, idx }});
|
|
|
+ }});
|
|
|
+
|
|
|
+ // 为每条边分配偏移索引
|
|
|
+ dedupedIntraLinks.forEach((link, idx) => {{
|
|
|
+ const srcId = link.source.id || link.source;
|
|
|
+ const group = intraEdgesBySource[srcId];
|
|
|
+ const indexInGroup = group.findIndex(item => item.idx === idx);
|
|
|
+ link._arcIndex = indexInGroup;
|
|
|
+ link._arcTotal = group.length;
|
|
|
+ }});
|
|
|
+
|
|
|
+ // 绘制层内边(默认隐藏)
|
|
|
+ const intraLinksGroup = g.append("g").attr("class", "intra-layer-links");
|
|
|
+ const intraLinks = intraLinksGroup.selectAll(".intra-link-group")
|
|
|
+ .data(dedupedIntraLinks)
|
|
|
+ .enter()
|
|
|
+ .append("g")
|
|
|
+ .attr("class", "intra-link-group")
|
|
|
+ .style("display", "none"); // 默认隐藏
|
|
|
+
|
|
|
+ // 层内边路径生成函数(弧线统一向上,弧度递增避免重叠)
|
|
|
+ const intraLinkPath = d => {{
|
|
|
+ const src = d.srcNode;
|
|
|
+ const tgt = d.tgtNode;
|
|
|
+ if (!src || !tgt) return "";
|
|
|
+
|
|
|
+ const dx = tgt.x - src.x;
|
|
|
+ const dy = tgt.y - src.y;
|
|
|
+ const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
+
|
|
|
+ // 根据边的索引计算弧度(统一向上)
|
|
|
+ const baseArc = Math.min(dist * 0.2, 30);
|
|
|
+ const arcStep = 20; // 每条边的弧度增量
|
|
|
+ const arcIndex = d._arcIndex || 0;
|
|
|
+ const arcHeight = baseArc + arcIndex * arcStep;
|
|
|
+
|
|
|
+ const midX = (src.x + tgt.x) / 2;
|
|
|
+ const midY = (src.y + tgt.y) / 2;
|
|
|
+
|
|
|
+ // 统一向上弯曲(Y减小)
|
|
|
+ return `M${{src.x}},${{src.y}} Q${{midX}},${{midY - arcHeight}} ${{tgt.x}},${{tgt.y}}`;
|
|
|
+ }};
|
|
|
+
|
|
|
+ // 层内边虚线配置(与左边一致)
|
|
|
+ const intraEdgeDash = {{
|
|
|
+ "匹配_相似": "5,5",
|
|
|
+ "包含": "2,2",
|
|
|
+ "镜像_包含": "2,2"
|
|
|
+ }};
|
|
|
+
|
|
|
+ // 层内边可见路径(样式与左边圆形图一致)
|
|
|
+ intraLinks.append("path")
|
|
|
+ .attr("class", d => "intra-link " + getEdgeClass(d.type))
|
|
|
+ .attr("d", intraLinkPath)
|
|
|
+ .attr("fill", "none")
|
|
|
+ .attr("stroke", d => graphEdgeColors[d.type] || "#f39c12")
|
|
|
+ .attr("stroke-width", 2)
|
|
|
+ .attr("stroke-opacity", 0.8)
|
|
|
+ .attr("stroke-dasharray", d => intraEdgeDash[d.type] || "none");
|
|
|
+
|
|
|
+ // 层内边热区(可点击,效果与左边圆一致)
|
|
|
+ intraLinks.append("path")
|
|
|
+ .attr("class", "intra-link-hitarea")
|
|
|
+ .attr("d", intraLinkPath)
|
|
|
+ .attr("fill", "none")
|
|
|
+ .attr("stroke", "transparent")
|
|
|
+ .attr("stroke-width", 15)
|
|
|
+ .style("cursor", "pointer")
|
|
|
+ .on("mouseover", function(event, d) {{
|
|
|
+ d3.select(this.parentNode).select(".intra-link")
|
|
|
+ .attr("stroke-width", 3)
|
|
|
+ .attr("stroke-opacity", 1);
|
|
|
+ }})
|
|
|
+ .on("mouseout", function(event, d) {{
|
|
|
+ d3.select(this.parentNode).select(".intra-link")
|
|
|
+ .attr("stroke-width", 2)
|
|
|
+ .attr("stroke-opacity", 0.8);
|
|
|
+ }})
|
|
|
+ .on("click", function(event, d) {{
|
|
|
+ event.stopPropagation();
|
|
|
+
|
|
|
+ // 在左边圆中找到对应的边
|
|
|
+ const srcId = d.source ? (d.source.id || d.source) : null;
|
|
|
+ const tgtId = d.target ? (d.target.id || d.target) : null;
|
|
|
+ let foundLink = null;
|
|
|
+ let foundIndex = -1;
|
|
|
+
|
|
|
+ links.forEach((link, idx) => {{
|
|
|
+ const lSrcId = typeof link.source === "object" ? link.source.id : link.source;
|
|
|
+ const lTgtId = typeof link.target === "object" ? link.target.id : link.target;
|
|
|
+ if ((lSrcId === srcId && lTgtId === tgtId) || (lSrcId === tgtId && lTgtId === srcId)) {{
|
|
|
+ if (link.type === d.type) {{
|
|
|
+ foundLink = link;
|
|
|
+ foundIndex = idx;
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+ }});
|
|
|
+
|
|
|
+ if (foundLink) {{
|
|
|
+ // 1. 高亮左边圆的边
|
|
|
+ highlightEdge(foundLink, foundIndex);
|
|
|
+ showEdgeInfo(foundLink);
|
|
|
+
|
|
|
+ // 2. 同步人设树高亮和关系图展示
|
|
|
+ const edgeDetail = foundLink.边详情 || {{}};
|
|
|
+ const isMirrorEdge = foundLink.type.startsWith("镜像_") || foundLink.type.startsWith("二阶_");
|
|
|
+
|
|
|
+ let personaPathNodes = [];
|
|
|
+ let edgeType;
|
|
|
+
|
|
|
+ if (isMirrorEdge && edgeDetail.路径节点) {{
|
|
|
+ personaPathNodes = edgeDetail.路径节点.filter(id => !id.startsWith("帖子_"));
|
|
|
+ edgeType = edgeDetail.原始边类型 || foundLink.type.replace("镜像_", "").replace("二阶_", "");
|
|
|
+ }} else {{
|
|
|
+ const sourceNode = typeof foundLink.source === "object" ? foundLink.source : nodes.find(n => n.id === foundLink.source);
|
|
|
+ const targetNode = typeof foundLink.target === "object" ? foundLink.target : nodes.find(n => n.id === foundLink.target);
|
|
|
+ const sId = sourceNode ? sourceNode.id : foundLink.source;
|
|
|
+ const tId = targetNode ? targetNode.id : foundLink.target;
|
|
|
+ if (!sId.startsWith("帖子_")) personaPathNodes.push(sId);
|
|
|
+ if (!tId.startsWith("帖子_")) personaPathNodes.push(tId);
|
|
|
+ edgeType = foundLink.type;
|
|
|
+ }}
|
|
|
+
|
|
|
+ syncTreeAndRelationGraph(personaPathNodes, edgeType);
|
|
|
+ }} else {{
|
|
|
+ // 没找到对应边,只显示详情
|
|
|
+ showEdgeInfo(d);
|
|
|
+ }}
|
|
|
+ }});
|
|
|
+
|
|
|
+ // 层内边分数标签(直接显示在弧线上)
|
|
|
+ // 计算二次贝塞尔曲线在 t=0.5 时的实际位置(曲线中点)
|
|
|
+ const getIntraArcMid = d => {{
|
|
|
+ const dist = Math.sqrt(
|
|
|
+ Math.pow(d.tgtNode.x - d.srcNode.x, 2) +
|
|
|
+ Math.pow(d.tgtNode.y - d.srcNode.y, 2)
|
|
|
+ );
|
|
|
+ const baseArc = Math.min(dist * 0.2, 30);
|
|
|
+ const arcStep = 20;
|
|
|
+ const arcIndex = d._arcIndex || 0;
|
|
|
+ const arcHeight = baseArc + arcIndex * arcStep;
|
|
|
+ // 二次贝塞尔曲线 t=0.5 时的点: (1-t)²*P0 + 2*(1-t)*t*P1 + t²*P2
|
|
|
+ // = 0.25*src + 0.5*ctrl + 0.25*tgt
|
|
|
+ const midY = (d.srcNode.y + d.tgtNode.y) / 2;
|
|
|
+ const ctrlY = midY - arcHeight;
|
|
|
+ return {{
|
|
|
+ x: (d.srcNode.x + d.tgtNode.x) / 2,
|
|
|
+ y: 0.25 * d.srcNode.y + 0.5 * ctrlY + 0.25 * d.tgtNode.y
|
|
|
+ }};
|
|
|
+ }};
|
|
|
+
|
|
|
+ // 分数背景(让文字在边上更清晰)
|
|
|
+ intraLinks.filter(d => d.score !== null && d.score !== undefined)
|
|
|
+ .append("rect")
|
|
|
+ .attr("class", "intra-link-score-bg")
|
|
|
+ .attr("x", d => getIntraArcMid(d).x - 14)
|
|
|
+ .attr("y", d => getIntraArcMid(d).y - 7)
|
|
|
+ .attr("width", 28)
|
|
|
+ .attr("height", 14)
|
|
|
+ .attr("rx", 3)
|
|
|
+ .attr("fill", "rgba(0,0,0,0.7)");
|
|
|
+
|
|
|
+ // 分数文字
|
|
|
+ intraLinks.filter(d => d.score !== null && d.score !== undefined)
|
|
|
+ .append("text")
|
|
|
+ .attr("class", "intra-link-score")
|
|
|
+ .attr("x", d => getIntraArcMid(d).x)
|
|
|
+ .attr("y", d => getIntraArcMid(d).y)
|
|
|
+ .attr("dy", "0.35em")
|
|
|
+ .attr("text-anchor", "middle")
|
|
|
+ .attr("fill", "#fff")
|
|
|
+ .attr("font-size", "9px")
|
|
|
+ .attr("font-weight", "bold")
|
|
|
+ .text(d => d.score.toFixed(2));
|
|
|
+
|
|
|
+ // 存储层内边引用供后续使用
|
|
|
+ window.intraLayerLinksRef = intraLinks;
|
|
|
+ window.intraLayerLinksData = dedupedIntraLinks;
|
|
|
+
|
|
|
postTreeActualHeight = targetExpandedY - (targetTagY - halfGap * 3) + 100;
|
|
|
}} // end if (postTree && postTree.root)
|
|
|
|
|
|
@@ -3384,70 +3680,57 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
}});
|
|
|
}}
|
|
|
|
|
|
- // 点击边时的高亮逻辑
|
|
|
- function highlightEdge(clickedLink, clickedIndex) {{
|
|
|
- const highlightNodeIds = new Set();
|
|
|
- const highlightLinkIndices = new Set([clickedIndex]);
|
|
|
+ // 收集单条边的高亮数据(供复用)
|
|
|
+ function collectEdgeHighlightData(clickedLink, clickedIndex) {{
|
|
|
+ const nodeIds = new Set();
|
|
|
+ const linkIndices = new Set([clickedIndex]);
|
|
|
+ const pathNodes = new Set();
|
|
|
|
|
|
const sourceId = typeof clickedLink.source === "object" ? clickedLink.source.id : clickedLink.source;
|
|
|
const targetId = typeof clickedLink.target === "object" ? clickedLink.target.id : clickedLink.target;
|
|
|
|
|
|
- highlightNodeIds.add(sourceId);
|
|
|
- highlightNodeIds.add(targetId);
|
|
|
+ nodeIds.add(sourceId);
|
|
|
+ nodeIds.add(targetId);
|
|
|
+ pathNodes.add(sourceId);
|
|
|
+ pathNodes.add(targetId);
|
|
|
|
|
|
- // 如果是镜像边或二阶边,使用预计算的路径节点高亮
|
|
|
+ // 如果是镜像边或二阶边,使用预计算的路径节点
|
|
|
if ((clickedLink.type.startsWith("镜像_") || clickedLink.type.startsWith("二阶_")) && clickedLink.边详情) {{
|
|
|
const detail = clickedLink.边详情;
|
|
|
- const pathNodes = new Set([sourceId, targetId]);
|
|
|
-
|
|
|
- // 使用预计算的路径节点
|
|
|
(detail.路径节点 || []).forEach(n => pathNodes.add(n));
|
|
|
-
|
|
|
- // 高亮路径上的所有节点
|
|
|
- pathNodes.forEach(n => highlightNodeIds.add(n));
|
|
|
-
|
|
|
- // 高亮连接路径节点的边
|
|
|
- links.forEach((link, i) => {{
|
|
|
- const lSrc = typeof link.source === "object" ? link.source.id : link.source;
|
|
|
- const lTgt = typeof link.target === "object" ? link.target.id : link.target;
|
|
|
- if (pathNodes.has(lSrc) && pathNodes.has(lTgt)) {{
|
|
|
- highlightLinkIndices.add(i);
|
|
|
- }}
|
|
|
- }});
|
|
|
}}
|
|
|
// 如果是人设边(两端都不是帖子节点),找对应的镜像边
|
|
|
else if (!sourceId.startsWith("帖子_") && !targetId.startsWith("帖子_")) {{
|
|
|
- // 收集路径节点
|
|
|
- const pathNodes = new Set([sourceId, targetId]);
|
|
|
-
|
|
|
- // 找匹配的镜像边,读取其路径节点
|
|
|
links.forEach((link, i) => {{
|
|
|
const detail = link.边详情 || {{}};
|
|
|
if ((link.type.startsWith("镜像_") || link.type.startsWith("二阶_")) && detail.源人设节点 && detail.目标人设节点) {{
|
|
|
const matches = (detail.源人设节点 === sourceId && detail.目标人设节点 === targetId) ||
|
|
|
(detail.源人设节点 === targetId && detail.目标人设节点 === sourceId);
|
|
|
if (matches) {{
|
|
|
- highlightLinkIndices.add(i);
|
|
|
- // 直接使用预存的路径节点
|
|
|
+ linkIndices.add(i);
|
|
|
(detail.路径节点 || []).forEach(n => pathNodes.add(n));
|
|
|
}}
|
|
|
}}
|
|
|
}});
|
|
|
+ }}
|
|
|
|
|
|
- // 高亮路径上的所有节点
|
|
|
- pathNodes.forEach(n => highlightNodeIds.add(n));
|
|
|
+ // 高亮路径上的所有节点
|
|
|
+ pathNodes.forEach(n => nodeIds.add(n));
|
|
|
|
|
|
- // 高亮连接路径节点的边
|
|
|
- links.forEach((link, i) => {{
|
|
|
- const lSrc = typeof link.source === "object" ? link.source.id : link.source;
|
|
|
- const lTgt = typeof link.target === "object" ? link.target.id : link.target;
|
|
|
- // 两端都在路径上的边
|
|
|
- if (pathNodes.has(lSrc) && pathNodes.has(lTgt)) {{
|
|
|
- highlightLinkIndices.add(i);
|
|
|
- }}
|
|
|
- }});
|
|
|
- }}
|
|
|
+ // 高亮连接路径节点的边
|
|
|
+ links.forEach((link, i) => {{
|
|
|
+ const lSrc = typeof link.source === "object" ? link.source.id : link.source;
|
|
|
+ const lTgt = typeof link.target === "object" ? link.target.id : link.target;
|
|
|
+ if (pathNodes.has(lSrc) && pathNodes.has(lTgt)) {{
|
|
|
+ linkIndices.add(i);
|
|
|
+ }}
|
|
|
+ }});
|
|
|
|
|
|
+ return {{ nodeIds, linkIndices, pathNodes }};
|
|
|
+ }}
|
|
|
+
|
|
|
+ // 应用高亮数据(统一处理高亮结果)
|
|
|
+ function applyEdgeHighlight(highlightNodeIds, highlightLinkIndices) {{
|
|
|
// 高亮相关的节点和边
|
|
|
highlightElements(highlightNodeIds, highlightLinkIndices);
|
|
|
|
|
|
@@ -3470,6 +3753,12 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
highlightRightTree(highlightNodeIds, highlightEdgeKeys);
|
|
|
}}
|
|
|
|
|
|
+ // 点击边时的高亮逻辑
|
|
|
+ function highlightEdge(clickedLink, clickedIndex) {{
|
|
|
+ const {{ nodeIds, linkIndices }} = collectEdgeHighlightData(clickedLink, clickedIndex);
|
|
|
+ applyEdgeHighlight(nodeIds, linkIndices);
|
|
|
+ }}
|
|
|
+
|
|
|
// 点击空白处清除高亮(合并所有空白点击逻辑)
|
|
|
svg.on("click", (event) => {{
|
|
|
// 检查是否点击的是空白区域(svg本身或layer-backgrounds)
|
|
|
@@ -4633,6 +4922,23 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
const key2 = `${{tgtId}}|${{srcId}}`;
|
|
|
return (pathEdgeKeys.has(key1) || pathEdgeKeys.has(key2)) ? 1 : 0.2;
|
|
|
}});
|
|
|
+
|
|
|
+ // 显示并高亮层内边(只显示左边圆形图中存在的边)
|
|
|
+ d3.selectAll(".intra-link-group").each(function(d) {{
|
|
|
+ const srcId = d.source ? (d.source.id || d.source) : null;
|
|
|
+ const tgtId = d.target ? (d.target.id || d.target) : null;
|
|
|
+ if (!srcId || !tgtId) {{
|
|
|
+ d3.select(this).style("display", "none");
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+ // 只显示在左边圆形图中高亮的边(来自 pathEdgeKeys)
|
|
|
+ const key1 = `${{srcId}}|${{tgtId}}`;
|
|
|
+ const key2 = `${{tgtId}}|${{srcId}}`;
|
|
|
+ const isInLeftGraph = pathEdgeKeys.has(key1) || pathEdgeKeys.has(key2);
|
|
|
+ d3.select(this)
|
|
|
+ .style("display", isInLeftGraph ? "block" : "none")
|
|
|
+ .style("opacity", isInLeftGraph ? 1 : 0.2);
|
|
|
+ }});
|
|
|
}}
|
|
|
|
|
|
// 恢复右边树的高亮状态
|
|
|
@@ -4650,6 +4956,11 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
|
|
|
// 恢复跨层边
|
|
|
d3.selectAll(".graph-link-group").style("opacity", 1);
|
|
|
+
|
|
|
+ // 隐藏层内边(恢复默认隐藏状态)
|
|
|
+ d3.selectAll(".intra-link-group")
|
|
|
+ .style("display", "none")
|
|
|
+ .style("opacity", 1);
|
|
|
}}
|
|
|
|
|
|
// 渲染路径上的所有节点和边(点击镜像边/二阶边时调用)
|