|
@@ -111,10 +111,178 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
height: 420px;
|
|
height: 420px;
|
|
|
border-top: 1px solid #0f3460;
|
|
border-top: 1px solid #0f3460;
|
|
|
display: none;
|
|
display: none;
|
|
|
|
|
+ position: relative;
|
|
|
}}
|
|
}}
|
|
|
#ego-container svg {{
|
|
#ego-container svg {{
|
|
|
display: block;
|
|
display: block;
|
|
|
}}
|
|
}}
|
|
|
|
|
+ .ego-controls {{
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 5px;
|
|
|
|
|
+ left: 5px;
|
|
|
|
|
+ right: 5px;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .ego-controls label {{
|
|
|
|
|
+ color: #8892b0;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 3px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .ego-controls select {{
|
|
|
|
|
+ background: #0f3460;
|
|
|
|
|
+ color: #e6e6e6;
|
|
|
|
|
+ border: 1px solid #1a1a2e;
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+ padding: 3px 6px;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-dropdown {{
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-btn {{
|
|
|
|
|
+ background: #0f3460;
|
|
|
|
|
+ color: #e6e6e6;
|
|
|
|
|
+ border: 1px solid #1a1a2e;
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+ padding: 4px 10px;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-btn:hover {{
|
|
|
|
|
+ background: #1a4a7a;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-btn::after {{
|
|
|
|
|
+ content: "▼";
|
|
|
|
|
+ font-size: 8px;
|
|
|
|
|
+ opacity: 0.6;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-menu {{
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 100%;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ background: #0f3460;
|
|
|
|
|
+ border: 1px solid #1a1a2e;
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ z-index: 100;
|
|
|
|
|
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
|
|
|
+ flex-direction: row;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-menu.show {{
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-levels {{
|
|
|
|
|
+ border-right: 1px solid #1a1a2e;
|
|
|
|
|
+ padding: 4px 0;
|
|
|
|
|
+ min-width: 50px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-level-item {{
|
|
|
|
|
+ padding: 6px 12px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ color: #e6e6e6;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-level-item:hover {{
|
|
|
|
|
+ background: rgba(233, 69, 96, 0.2);
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-level-item.active {{
|
|
|
|
|
+ background: rgba(233, 69, 96, 0.3);
|
|
|
|
|
+ color: #e94560;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-level-item::after {{
|
|
|
|
|
+ content: "›";
|
|
|
|
|
+ margin-left: 8px;
|
|
|
|
|
+ opacity: 0.5;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edges {{
|
|
|
|
|
+ padding: 4px 0;
|
|
|
|
|
+ min-width: 100px;
|
|
|
|
|
+ max-height: 300px;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-group {{
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-group.active {{
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-group-header {{
|
|
|
|
|
+ color: #e94560;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ font-size: 9px;
|
|
|
|
|
+ padding: 4px 10px 2px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .select-all {{
|
|
|
|
|
+ font-weight: normal;
|
|
|
|
|
+ color: #8892b0;
|
|
|
|
|
+ font-size: 9px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 3px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .select-all input {{
|
|
|
|
|
+ width: 10px;
|
|
|
|
|
+ height: 10px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .header-actions {{
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .invert-btn, .reset-btn {{
|
|
|
|
|
+ color: #8892b0;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font-size: 9px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .invert-btn:hover {{
|
|
|
|
|
+ color: #e94560;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .reset-btn:hover {{
|
|
|
|
|
+ color: #2ecc71;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-item {{
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+ padding: 4px 10px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ color: #e6e6e6;
|
|
|
|
|
+ font-size: 10px;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-item:hover {{
|
|
|
|
|
+ background: rgba(233, 69, 96, 0.15);
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-item input {{
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-item .edge-color {{
|
|
|
|
|
+ width: 12px;
|
|
|
|
|
+ height: 12px;
|
|
|
|
|
+ border-radius: 2px;
|
|
|
|
|
+ border: 1px solid #333;
|
|
|
|
|
+ opacity: 0.3;
|
|
|
|
|
+ }}
|
|
|
|
|
+ .cascade-edge-item input:checked + .edge-color {{
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ }}
|
|
|
#graph {{
|
|
#graph {{
|
|
|
flex: 1;
|
|
flex: 1;
|
|
|
position: relative;
|
|
position: relative;
|
|
@@ -586,7 +754,48 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
<div class="main-content">
|
|
<div class="main-content">
|
|
|
<div id="left-panel">
|
|
<div id="left-panel">
|
|
|
<div id="tree-container"></div>
|
|
<div id="tree-container"></div>
|
|
|
- <div id="ego-container"></div>
|
|
|
|
|
|
|
+ <div id="ego-container">
|
|
|
|
|
+ <div class="ego-controls">
|
|
|
|
|
+ <div class="cascade-dropdown">
|
|
|
|
|
+ <div class="cascade-btn" onclick="toggleCascadeMenu()">
|
|
|
|
|
+ <span id="cascade-label">1级</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="cascade-menu" id="cascade-menu">
|
|
|
|
|
+ <div class="cascade-levels">
|
|
|
|
|
+ <div class="cascade-level-item active" data-level="1" onclick="selectLevel(1)">1级</div>
|
|
|
|
|
+ <div class="cascade-level-item" data-level="2" onclick="selectLevel(2)">2级</div>
|
|
|
|
|
+ <div class="cascade-level-item" data-level="3" onclick="selectLevel(3)">3级</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="cascade-edges">
|
|
|
|
|
+ <div class="cascade-edge-group active" data-level="1">
|
|
|
|
|
+ <div class="cascade-edge-group-header">L1 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="1" data-select-all checked>全选</label><span class="invert-btn" data-level="1" onclick="invertSelection(1)">反选</span><span class="reset-btn" onclick="resetSelection()">重置</span></span></div>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="属于" checked><span class="edge-color" style="background:#9b59b6"></span>属于</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="包含" checked><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="分类共现(跨点)" checked><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="分类共现(点内)" checked><span class="edge-color" style="background:#3498db"></span>点内共现</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="1" data-type="标签共现" checked><span class="edge-color" style="background:#f39c12"></span>标签共现</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="cascade-edge-group" data-level="2">
|
|
|
|
|
+ <div class="cascade-edge-group-header">L2 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="2" data-select-all>全选</label><span class="invert-btn" data-level="2" onclick="invertSelection(2)">反选</span></span></div>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="属于"><span class="edge-color" style="background:#9b59b6"></span>属于</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="包含" checked><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="分类共现(跨点)"><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="分类共现(点内)"><span class="edge-color" style="background:#3498db"></span>点内共现</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="2" data-type="标签共现"><span class="edge-color" style="background:#f39c12"></span>标签共现</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="cascade-edge-group" data-level="3">
|
|
|
|
|
+ <div class="cascade-edge-group-header">L3 <span class="header-actions"><label class="select-all"><input type="checkbox" data-level="3" data-select-all>全选</label><span class="invert-btn" data-level="3" onclick="invertSelection(3)">反选</span></span></div>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="属于"><span class="edge-color" style="background:#9b59b6"></span>属于</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="包含" checked><span class="edge-color" style="background:#ffb6c1"></span>包含</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="分类共现(跨点)"><span class="edge-color" style="background:#2ecc71"></span>跨点共现</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="分类共现(点内)"><span class="edge-color" style="background:#3498db"></span>点内共现</label>
|
|
|
|
|
+ <label class="cascade-edge-item"><input type="checkbox" data-level="3" data-type="标签共现"><span class="edge-color" style="background:#f39c12"></span>标签共现</label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
<div id="graph">
|
|
<div id="graph">
|
|
|
<div class="tooltip" id="tooltip"></div>
|
|
<div class="tooltip" id="tooltip"></div>
|
|
@@ -766,6 +975,47 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
tab.addEventListener("click", () => switchTab(index));
|
|
tab.addEventListener("click", () => switchTab(index));
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
|
|
+ // 绑定关系图控制面板事件
|
|
|
|
|
+ // 全选复选框事件
|
|
|
|
|
+ document.querySelectorAll('.select-all input').forEach(cb => {{
|
|
|
|
|
+ cb.addEventListener('change', (e) => {{
|
|
|
|
|
+ const level = e.target.dataset.level;
|
|
|
|
|
+ const checked = e.target.checked;
|
|
|
|
|
+ // 设置该层级所有边类型复选框
|
|
|
|
|
+ document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(item => {{
|
|
|
|
|
+ item.checked = checked;
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 重新渲染关系图和人设树高亮
|
|
|
|
|
+ if (currentEgoCenterNodeId) {{
|
|
|
|
|
+ handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 边类型复选框变化事件
|
|
|
|
|
+ document.querySelectorAll('.cascade-edge-item input').forEach(cb => {{
|
|
|
|
|
+ cb.addEventListener('change', (e) => {{
|
|
|
|
|
+ // 更新全选复选框状态
|
|
|
|
|
+ const level = e.target.dataset.level;
|
|
|
|
|
+ const allItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`);
|
|
|
|
|
+ const checkedItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]:checked`);
|
|
|
|
|
+ const selectAllCb = document.querySelector(`.select-all input[data-level="${{level}}"]`);
|
|
|
|
|
+ selectAllCb.checked = allItems.length === checkedItems.length;
|
|
|
|
|
+
|
|
|
|
|
+ // 重新渲染关系图和人设树高亮
|
|
|
|
|
+ if (currentEgoCenterNodeId) {{
|
|
|
|
|
+ handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 点击页面其他地方关闭级联菜单
|
|
|
|
|
+ document.addEventListener('click', (e) => {{
|
|
|
|
|
+ if (!e.target.closest('.cascade-dropdown')) {{
|
|
|
|
|
+ document.getElementById('cascade-menu').classList.remove('show');
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
// 显示第一个帖子
|
|
// 显示第一个帖子
|
|
|
switchTab(0);
|
|
switchTab(0);
|
|
|
}}
|
|
}}
|
|
@@ -1461,14 +1711,6 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.attr("class", "ego-center")
|
|
.attr("class", "ego-center")
|
|
|
.attr("transform", `translate(200, 210)`);
|
|
.attr("transform", `translate(200, 210)`);
|
|
|
|
|
|
|
|
- // 关系图背景圆
|
|
|
|
|
- egoCenterGroup.append("circle")
|
|
|
|
|
- .attr("class", "ego-background")
|
|
|
|
|
- .attr("r", egoDisplayRadius)
|
|
|
|
|
- .attr("fill", "rgba(233, 69, 96, 0.08)")
|
|
|
|
|
- .attr("stroke", "rgba(233, 69, 96, 0.3)")
|
|
|
|
|
- .attr("stroke-width", 2);
|
|
|
|
|
-
|
|
|
|
|
// 关系图标题
|
|
// 关系图标题
|
|
|
egoCenterGroup.append("text")
|
|
egoCenterGroup.append("text")
|
|
|
.attr("class", "ego-title")
|
|
.attr("class", "ego-title")
|
|
@@ -1520,7 +1762,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
const treeEdgeColors = {{
|
|
const treeEdgeColors = {{
|
|
|
"分类层级": "#2ecc71", // 绿色 - 分类的层级关系
|
|
"分类层级": "#2ecc71", // 绿色 - 分类的层级关系
|
|
|
"属于": "#9b59b6", // 紫色 - 标签属于分类
|
|
"属于": "#9b59b6", // 紫色 - 标签属于分类
|
|
|
- "包含": "#8e44ad", // 深紫 - 分类包含标签
|
|
|
|
|
|
|
+ "包含": "#ffb6c1", // 淡粉 - 分类包含标签(向下)
|
|
|
"分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
|
|
"分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
|
|
|
"分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
|
|
"分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
|
|
|
"标签共现": "#f39c12" // 橙色 - 标签共现
|
|
"标签共现": "#f39c12" // 橙色 - 标签共现
|
|
@@ -1534,27 +1776,29 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
"标签共现": "2,2"
|
|
"标签共现": "2,2"
|
|
|
}};
|
|
}};
|
|
|
|
|
|
|
|
- // 只保留"属于"边用于树的展示(其他边用专门子图展示)
|
|
|
|
|
- const visibleTreeEdges = personaTreeData.edges.filter(e => {{
|
|
|
|
|
- return e.边类型 === "属于" && treeNodeById[e.源节点ID] && treeNodeById[e.目标节点ID];
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ // 只用D3树布局的链接作为树边(避免重复)
|
|
|
|
|
+ const visibleTreeEdges = root.links().map(link => ({{
|
|
|
|
|
+ 源节点ID: link.source.data.节点ID || link.source.data.name,
|
|
|
|
|
+ 目标节点ID: link.target.data.节点ID || link.target.data.name,
|
|
|
|
|
+ 边类型: "属于",
|
|
|
|
|
+ isTreeLink: true,
|
|
|
|
|
+ sourceNode: link.source,
|
|
|
|
|
+ targetNode: link.target
|
|
|
|
|
+ }}));
|
|
|
|
|
|
|
|
- // 添加树结构边(根节点->维度,维度->子节点)
|
|
|
|
|
- root.links().forEach(link => {{
|
|
|
|
|
- visibleTreeEdges.push({{
|
|
|
|
|
- 源节点ID: link.source.data.节点ID || link.source.data.name,
|
|
|
|
|
- 目标节点ID: link.target.data.节点ID || link.target.data.name,
|
|
|
|
|
- 边类型: "属于",
|
|
|
|
|
- isTreeLink: true,
|
|
|
|
|
- sourceNode: link.source,
|
|
|
|
|
- targetNode: link.target
|
|
|
|
|
- }});
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ // 生成树边路径的函数
|
|
|
|
|
+ function treeEdgePath(d) {{
|
|
|
|
|
+ const source = d.sourceNode;
|
|
|
|
|
+ const target = d.targetNode;
|
|
|
|
|
+ if (!source || !target) return "";
|
|
|
|
|
+ const midX = (source.y + target.y) / 2;
|
|
|
|
|
+ return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
|
|
|
|
|
+ }}
|
|
|
|
|
|
|
|
- // 绘制原始边(分类层级、标签属于)
|
|
|
|
|
|
|
+ // 绘制原始边(只有属于边)
|
|
|
const treeEdgeGroup = treeGroup.append("g").attr("class", "tree-edges");
|
|
const treeEdgeGroup = treeGroup.append("g").attr("class", "tree-edges");
|
|
|
|
|
|
|
|
- // 先绘制可见边
|
|
|
|
|
|
|
+ // 先绘制可见边(只有属于边)
|
|
|
const treeEdges = treeEdgeGroup.selectAll(".tree-edge")
|
|
const treeEdges = treeEdgeGroup.selectAll(".tree-edge")
|
|
|
.data(visibleTreeEdges)
|
|
.data(visibleTreeEdges)
|
|
|
.join("path")
|
|
.join("path")
|
|
@@ -1563,13 +1807,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.attr("stroke", "#9b59b6") // 属于边用紫色
|
|
.attr("stroke", "#9b59b6") // 属于边用紫色
|
|
|
.attr("stroke-opacity", 0.3)
|
|
.attr("stroke-opacity", 0.3)
|
|
|
.attr("stroke-width", 1)
|
|
.attr("stroke-width", 1)
|
|
|
- .attr("d", d => {{
|
|
|
|
|
- const source = d.isTreeLink ? d.sourceNode : treeNodeById[d.源节点ID];
|
|
|
|
|
- const target = d.isTreeLink ? d.targetNode : treeNodeById[d.目标节点ID];
|
|
|
|
|
- if (!source || !target) return "";
|
|
|
|
|
- const midX = (source.y + target.y) / 2;
|
|
|
|
|
- return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ .attr("d", treeEdgePath);
|
|
|
|
|
|
|
|
// 绘制透明的热区边(便于点击)
|
|
// 绘制透明的热区边(便于点击)
|
|
|
const treeEdgeHitareas = treeEdgeGroup.selectAll(".tree-edge-hitarea")
|
|
const treeEdgeHitareas = treeEdgeGroup.selectAll(".tree-edge-hitarea")
|
|
@@ -1580,13 +1818,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.attr("stroke", "transparent")
|
|
.attr("stroke", "transparent")
|
|
|
.attr("stroke-width", 12)
|
|
.attr("stroke-width", 12)
|
|
|
.style("cursor", "pointer")
|
|
.style("cursor", "pointer")
|
|
|
- .attr("d", d => {{
|
|
|
|
|
- const source = d.isTreeLink ? d.sourceNode : treeNodeById[d.源节点ID];
|
|
|
|
|
- const target = d.isTreeLink ? d.targetNode : treeNodeById[d.目标节点ID];
|
|
|
|
|
- if (!source || !target) return "";
|
|
|
|
|
- const midX = (source.y + target.y) / 2;
|
|
|
|
|
- return `M${{source.y}},${{source.x}} C${{midX}},${{source.x}} ${{midX}},${{target.x}} ${{target.y}},${{target.x}}`;
|
|
|
|
|
- }})
|
|
|
|
|
|
|
+ .attr("d", treeEdgePath)
|
|
|
.on("click", (event, d) => {{
|
|
.on("click", (event, d) => {{
|
|
|
event.stopPropagation();
|
|
event.stopPropagation();
|
|
|
// 调用共享的边点击处理函数
|
|
// 调用共享的边点击处理函数
|
|
@@ -2400,44 +2632,19 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
|
|
if (n.data.节点ID) treeNodeById[n.data.节点ID] = n;
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
- // 查找所有与该节点相关的边
|
|
|
|
|
- const allConnectedEdges = personaTreeData.edges.filter(e =>
|
|
|
|
|
- e.源节点ID === clickedNodeId || e.目标节点ID === clickedNodeId
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // 收集所有相关节点ID
|
|
|
|
|
- const connectedNodeIds = new Set();
|
|
|
|
|
- connectedNodeIds.add(clickedNodeId);
|
|
|
|
|
- allConnectedEdges.forEach(e => {{
|
|
|
|
|
- connectedNodeIds.add(e.源节点ID);
|
|
|
|
|
- connectedNodeIds.add(e.目标节点ID);
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ // 使用多级配置收集关联节点(与关系图一致)
|
|
|
|
|
+ const levelConfigs = getLevelConfigs();
|
|
|
|
|
+ const {{ nodeIds: connectedNodeIds, edges: allConnectedEdges }} = collectNodesAndEdges(clickedNodeId, levelConfigs);
|
|
|
|
|
|
|
|
// 获取D3树节点
|
|
// 获取D3树节点
|
|
|
const clickedD3Node = treeNodeById[clickedNodeId];
|
|
const clickedD3Node = treeNodeById[clickedNodeId];
|
|
|
|
|
|
|
|
- // 也添加树结构中的父子节点
|
|
|
|
|
- if (clickedD3Node) {{
|
|
|
|
|
- if (clickedD3Node.parent && clickedD3Node.parent.data.节点ID) {{
|
|
|
|
|
- connectedNodeIds.add(clickedD3Node.parent.data.节点ID);
|
|
|
|
|
- }}
|
|
|
|
|
- if (clickedD3Node.children) {{
|
|
|
|
|
- clickedD3Node.children.forEach(c => {{
|
|
|
|
|
- if (c.data.节点ID) connectedNodeIds.add(c.data.节点ID);
|
|
|
|
|
- }});
|
|
|
|
|
- }}
|
|
|
|
|
- }}
|
|
|
|
|
-
|
|
|
|
|
- // 转换为D3节点集合
|
|
|
|
|
|
|
+ // 转换为D3节点集合(只基于 collectNodesAndEdges 的结果,与关系图同步)
|
|
|
const connectedD3Nodes = new Set();
|
|
const connectedD3Nodes = new Set();
|
|
|
if (clickedD3Node) connectedD3Nodes.add(clickedD3Node);
|
|
if (clickedD3Node) connectedD3Nodes.add(clickedD3Node);
|
|
|
connectedNodeIds.forEach(id => {{
|
|
connectedNodeIds.forEach(id => {{
|
|
|
if (treeNodeById[id]) connectedD3Nodes.add(treeNodeById[id]);
|
|
if (treeNodeById[id]) connectedD3Nodes.add(treeNodeById[id]);
|
|
|
}});
|
|
}});
|
|
|
- if (clickedD3Node) {{
|
|
|
|
|
- if (clickedD3Node.parent) connectedD3Nodes.add(clickedD3Node.parent);
|
|
|
|
|
- if (clickedD3Node.children) clickedD3Node.children.forEach(c => connectedD3Nodes.add(c));
|
|
|
|
|
- }}
|
|
|
|
|
|
|
|
|
|
// 高亮树中的节点
|
|
// 高亮树中的节点
|
|
|
treeGroup.selectAll(".tree-node")
|
|
treeGroup.selectAll(".tree-node")
|
|
@@ -2462,11 +2669,14 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
return (n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb";
|
|
return (n.data.isRoot || n.data.isDimension) ? getTreeNodeColor(n) : "#bbb";
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
- // 边高亮
|
|
|
|
|
|
|
+ // 边高亮(树边基于连接的节点)
|
|
|
treeEdges.attr("stroke-opacity", function(e) {{
|
|
treeEdges.attr("stroke-opacity", function(e) {{
|
|
|
- const isConnected = (e.源节点ID === clickedNodeId || e.目标节点ID === clickedNodeId) ||
|
|
|
|
|
- (e.isTreeLink && (e.源节点ID === clickedNodeName || e.目标节点ID === clickedNodeName));
|
|
|
|
|
- return isConnected ? 0.9 : 0.15;
|
|
|
|
|
|
|
+ // 树边高亮条件:子节点(目标)在连接集中,或者两端都在连接集中
|
|
|
|
|
+ const srcId = e.源节点ID;
|
|
|
|
|
+ const tgtId = e.目标节点ID;
|
|
|
|
|
+ const srcConnected = connectedNodeIds.has(srcId) || connectedD3Nodes.has(e.sourceNode);
|
|
|
|
|
+ const tgtConnected = connectedNodeIds.has(tgtId) || connectedD3Nodes.has(e.targetNode);
|
|
|
|
|
+ return (srcConnected && tgtConnected) ? 0.9 : 0.15;
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
// 计算入边和出边
|
|
// 计算入边和出边
|
|
@@ -2654,8 +2864,149 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
|
|
|
|
|
// 关系子图(Ego Graph)- 在画布第四层显示
|
|
// 关系子图(Ego Graph)- 在画布第四层显示
|
|
|
let currentEgoSimulation = null; // 保存当前的力模拟,用于停止
|
|
let currentEgoSimulation = null; // 保存当前的力模拟,用于停止
|
|
|
|
|
+ let currentEgoCenterNodeId = null; // 当前关系图中心节点ID
|
|
|
|
|
+ let currentEgoCenterNodeName = null; // 当前关系图中心节点名称
|
|
|
|
|
+
|
|
|
|
|
+ // 当前选中的层级数
|
|
|
|
|
+ let currentLevelCount = 1;
|
|
|
|
|
+
|
|
|
|
|
+ // 切换级联菜单
|
|
|
|
|
+ function toggleCascadeMenu() {{
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ const menu = document.getElementById('cascade-menu');
|
|
|
|
|
+ menu.classList.toggle('show');
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 反选指定层级的边类型
|
|
|
|
|
+ function invertSelection(level) {{
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(cb => {{
|
|
|
|
|
+ cb.checked = !cb.checked;
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 更新全选复选框状态
|
|
|
|
|
+ const allItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`);
|
|
|
|
|
+ const checkedItems = document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]:checked`);
|
|
|
|
|
+ const selectAllCb = document.querySelector(`.select-all input[data-level="${{level}}"]`);
|
|
|
|
|
+ selectAllCb.checked = allItems.length === checkedItems.length;
|
|
|
|
|
+ // 重新渲染
|
|
|
|
|
+ if (currentEgoCenterNodeId) {{
|
|
|
|
|
+ handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 重置所有层级到默认配置(1级全选,2/3级只选包含)
|
|
|
|
|
+ function resetSelection() {{
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ // Level 1: 全选
|
|
|
|
|
+ document.querySelectorAll('.cascade-edge-item input[data-level="1"]').forEach(cb => {{
|
|
|
|
|
+ cb.checked = true;
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelector('.select-all input[data-level="1"]').checked = true;
|
|
|
|
|
+
|
|
|
|
|
+ // Level 2 & 3: 只选包含
|
|
|
|
|
+ [2, 3].forEach(level => {{
|
|
|
|
|
+ document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]`).forEach(cb => {{
|
|
|
|
|
+ cb.checked = (cb.dataset.type === "包含");
|
|
|
|
|
+ }});
|
|
|
|
|
+ document.querySelector(`.select-all input[data-level="${{level}}"]`).checked = false;
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 重新渲染
|
|
|
|
|
+ if (currentEgoCenterNodeId) {{
|
|
|
|
|
+ handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 选择层级
|
|
|
|
|
+ function selectLevel(level) {{
|
|
|
|
|
+ event.stopPropagation();
|
|
|
|
|
+ currentLevelCount = level;
|
|
|
|
|
+ // 更新层级选中状态
|
|
|
|
|
+ document.querySelectorAll('.cascade-level-item').forEach(item => {{
|
|
|
|
|
+ const itemLevel = parseInt(item.dataset.level);
|
|
|
|
|
+ item.classList.toggle('active', itemLevel === level);
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 显示对应的边类型组(1级显示L1,2级显示L1+L2,3级显示L1+L2+L3)
|
|
|
|
|
+ document.querySelectorAll('.cascade-edge-group').forEach(group => {{
|
|
|
|
|
+ const groupLevel = parseInt(group.dataset.level);
|
|
|
|
|
+ group.classList.toggle('active', groupLevel <= level);
|
|
|
|
|
+ }});
|
|
|
|
|
+ // 更新按钮显示
|
|
|
|
|
+ document.getElementById('cascade-label').textContent = level + '级';
|
|
|
|
|
+ // 重新渲染关系图和人设树高亮
|
|
|
|
|
+ if (currentEgoCenterNodeId) {{
|
|
|
|
|
+ handleNodeClick(currentEgoCenterNodeId, currentEgoCenterNodeName);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 获取指定层级选中的边类型
|
|
|
|
|
+ function getLevelEdgeTypes(level) {{
|
|
|
|
|
+ const types = [];
|
|
|
|
|
+ document.querySelectorAll(`.cascade-edge-item input[data-level="${{level}}"]:checked`).forEach(cb => {{
|
|
|
|
|
+ types.push(cb.dataset.type);
|
|
|
|
|
+ }});
|
|
|
|
|
+ return types;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 获取层级配置(返回每层的边类型数组)
|
|
|
|
|
+ function getLevelConfigs() {{
|
|
|
|
|
+ const configs = [];
|
|
|
|
|
+ for (let i = 1; i <= currentLevelCount; i++) {{
|
|
|
|
|
+ const types = getLevelEdgeTypes(i);
|
|
|
|
|
+ configs.push(types);
|
|
|
|
|
+ }}
|
|
|
|
|
+ return configs;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ // 多层级展开:收集指定层级内的所有节点和边(每层可指定不同边类型)
|
|
|
|
|
+ function collectNodesAndEdges(centerNodeId, levelConfigs) {{
|
|
|
|
|
+ const nodeIds = new Set([centerNodeId]);
|
|
|
|
|
+ const collectedEdges = [];
|
|
|
|
|
+ const visitedEdges = new Set();
|
|
|
|
|
+
|
|
|
|
|
+ let currentLayer = new Set([centerNodeId]);
|
|
|
|
|
+
|
|
|
|
|
+ for (let d = 0; d < levelConfigs.length; d++) {{
|
|
|
|
|
+ const edgeTypes = levelConfigs[d];
|
|
|
|
|
+ if (edgeTypes.length === 0) break;
|
|
|
|
|
+
|
|
|
|
|
+ const nextLayer = new Set();
|
|
|
|
|
+
|
|
|
|
|
+ personaTreeData.edges.forEach(e => {{
|
|
|
|
|
+ // 筛选边类型
|
|
|
|
|
+ if (!edgeTypes.includes(e.边类型)) return;
|
|
|
|
|
+
|
|
|
|
|
+ const edgeKey = `${{e.源节点ID}}-${{e.目标节点ID}}-${{e.边类型}}`;
|
|
|
|
|
+ if (visitedEdges.has(edgeKey)) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 单向查找:只从源节点出发,沿边的方向查找
|
|
|
|
|
+ // 属于:子→父(从子出发找父)
|
|
|
|
|
+ // 包含:父→子(从父出发找子)
|
|
|
|
|
+ // 共现:A→B(从A出发找B)
|
|
|
|
|
+ if (currentLayer.has(e.源节点ID)) {{
|
|
|
|
|
+ visitedEdges.add(edgeKey);
|
|
|
|
|
+ collectedEdges.push({{...e, _level: d + 1}});
|
|
|
|
|
+
|
|
|
|
|
+ // 添加目标节点到下一层
|
|
|
|
|
+ if (!nodeIds.has(e.目标节点ID)) {{
|
|
|
|
|
+ nextLayer.add(e.目标节点ID);
|
|
|
|
|
+ nodeIds.add(e.目标节点ID);
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ currentLayer = nextLayer;
|
|
|
|
|
+ if (nextLayer.size === 0) break;
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
|
|
+ return {{ nodeIds, edges: collectedEdges, depth: levelConfigs.length }};
|
|
|
|
|
+ }}
|
|
|
|
|
|
|
|
function renderEgoGraph(centerNodeId, centerNodeName) {{
|
|
function renderEgoGraph(centerNodeId, centerNodeName) {{
|
|
|
|
|
+ // 保存当前中心节点
|
|
|
|
|
+ currentEgoCenterNodeId = centerNodeId;
|
|
|
|
|
+ currentEgoCenterNodeName = centerNodeName;
|
|
|
|
|
+
|
|
|
// 显示关系图容器
|
|
// 显示关系图容器
|
|
|
document.getElementById("ego-container").style.display = "block";
|
|
document.getElementById("ego-container").style.display = "block";
|
|
|
|
|
|
|
@@ -2672,28 +3023,22 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
// 清除旧内容
|
|
// 清除旧内容
|
|
|
egoGroup.selectAll("*").remove();
|
|
egoGroup.selectAll("*").remove();
|
|
|
|
|
|
|
|
|
|
+ // 获取当前层级配置
|
|
|
|
|
+ const levelConfigs = getLevelConfigs();
|
|
|
|
|
+
|
|
|
// 更新标题
|
|
// 更新标题
|
|
|
d3.select(".ego-container .ego-title").text(`关系图: ${{centerNodeName}}`);
|
|
d3.select(".ego-container .ego-title").text(`关系图: ${{centerNodeName}}`);
|
|
|
|
|
|
|
|
// 获取层半径
|
|
// 获取层半径
|
|
|
const radius = 160; // 固定半径
|
|
const radius = 160; // 固定半径
|
|
|
|
|
|
|
|
- // 找到所有相关的边
|
|
|
|
|
- const relatedEdges = personaTreeData.edges.filter(e =>
|
|
|
|
|
- e.源节点ID === centerNodeId || e.目标节点ID === centerNodeId
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // 收集所有相关节点ID
|
|
|
|
|
- const nodeIds = new Set([centerNodeId]);
|
|
|
|
|
- relatedEdges.forEach(e => {{
|
|
|
|
|
- nodeIds.add(e.源节点ID);
|
|
|
|
|
- nodeIds.add(e.目标节点ID);
|
|
|
|
|
- }});
|
|
|
|
|
|
|
+ // 根据层级配置收集节点和边
|
|
|
|
|
+ const {{ nodeIds, edges: relatedEdges, depth }} = collectNodesAndEdges(centerNodeId, levelConfigs);
|
|
|
|
|
|
|
|
- // 构建节点数据
|
|
|
|
|
|
|
+ // 构建节点数据(确保中心节点始终包含)
|
|
|
const nodeMap = {{}};
|
|
const nodeMap = {{}};
|
|
|
personaTreeData.nodes.forEach(n => {{
|
|
personaTreeData.nodes.forEach(n => {{
|
|
|
- if (nodeIds.has(n.节点ID)) {{
|
|
|
|
|
|
|
+ if (nodeIds.has(n.节点ID) || n.节点ID === centerNodeId) {{
|
|
|
nodeMap[n.节点ID] = {{
|
|
nodeMap[n.节点ID] = {{
|
|
|
id: n.节点ID,
|
|
id: n.节点ID,
|
|
|
name: n.节点名称,
|
|
name: n.节点名称,
|
|
@@ -2705,11 +3050,35 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
const nodes = Object.values(nodeMap);
|
|
const nodes = Object.values(nodeMap);
|
|
|
- const links = relatedEdges.map(e => ({{
|
|
|
|
|
- source: e.源节点ID,
|
|
|
|
|
- target: e.目标节点ID,
|
|
|
|
|
- type: e.边类型
|
|
|
|
|
- }}));
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 处理同一对节点间的多条边,分配不同弧度
|
|
|
|
|
+ const linksByPair = {{}};
|
|
|
|
|
+ relatedEdges.forEach(e => {{
|
|
|
|
|
+ // 用排序后的节点对作为key,确保A-B和B-A是同一对
|
|
|
|
|
+ const pairKey = [e.源节点ID, e.目标节点ID].sort().join("||");
|
|
|
|
|
+ if (!linksByPair[pairKey]) linksByPair[pairKey] = [];
|
|
|
|
|
+ linksByPair[pairKey].push(e);
|
|
|
|
|
+ }});
|
|
|
|
|
+
|
|
|
|
|
+ // 为每条边分配弧度索引
|
|
|
|
|
+ const links = [];
|
|
|
|
|
+ Object.values(linksByPair).forEach(pairEdges => {{
|
|
|
|
|
+ const count = pairEdges.length;
|
|
|
|
|
+ pairEdges.forEach((e, i) => {{
|
|
|
|
|
+ // 弧度:0表示直线,正负值表示向两侧弯曲
|
|
|
|
|
+ let curvature = 0;
|
|
|
|
|
+ if (count > 1) {{
|
|
|
|
|
+ // 多条边时,均匀分布弧度
|
|
|
|
|
+ curvature = (i - (count - 1) / 2) * 0.3;
|
|
|
|
|
+ }}
|
|
|
|
|
+ links.push({{
|
|
|
|
|
+ source: e.源节点ID,
|
|
|
|
|
+ target: e.目标节点ID,
|
|
|
|
|
+ type: e.边类型,
|
|
|
|
|
+ curvature: curvature
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
+ }});
|
|
|
|
|
|
|
|
// 根据节点数量动态计算实际使用的半径
|
|
// 根据节点数量动态计算实际使用的半径
|
|
|
const nodeCount = nodes.length;
|
|
const nodeCount = nodes.length;
|
|
@@ -2725,7 +3094,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
actualRadius = Math.max(minEgoRadius, Math.min(maxEgoRadius, calcR));
|
|
actualRadius = Math.max(minEgoRadius, Math.min(maxEgoRadius, calcR));
|
|
|
}}
|
|
}}
|
|
|
|
|
|
|
|
- // 显示节点名称作为标题
|
|
|
|
|
|
|
+ // 显示节点名称作为标题(包含层级和节点数)
|
|
|
egoGroup.append("text")
|
|
egoGroup.append("text")
|
|
|
.attr("class", "ego-title")
|
|
.attr("class", "ego-title")
|
|
|
.attr("y", -actualRadius - 15)
|
|
.attr("y", -actualRadius - 15)
|
|
@@ -2733,21 +3102,59 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
.attr("fill", "#e94560")
|
|
.attr("fill", "#e94560")
|
|
|
.attr("font-size", "12px")
|
|
.attr("font-size", "12px")
|
|
|
.attr("font-weight", "bold")
|
|
.attr("font-weight", "bold")
|
|
|
- .text(centerNodeName + ` (${{nodeCount}}个节点)`);
|
|
|
|
|
|
|
+ .text(`${{centerNodeName}} (${{depth}}级, ${{nodeCount}}节点, ${{relatedEdges.length}}边)`);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果只有中心节点,只渲染中心节点
|
|
|
|
|
+ if (nodes.length === 1) {{
|
|
|
|
|
+ const centerNode = nodes[0];
|
|
|
|
|
+ // 获取中心节点的维度颜色
|
|
|
|
|
+ const dimColors = {{
|
|
|
|
|
+ "灵感点": "#f39c12",
|
|
|
|
|
+ "目的点": "#3498db",
|
|
|
|
|
+ "关键点": "#9b59b6"
|
|
|
|
|
+ }};
|
|
|
|
|
+ const level = centerNode.level || "";
|
|
|
|
|
+ let fillColor = "#888";
|
|
|
|
|
+ if (level.includes("灵感点")) fillColor = dimColors["灵感点"];
|
|
|
|
|
+ else if (level.includes("目的点")) fillColor = dimColors["目的点"];
|
|
|
|
|
+ else if (level.includes("关键点")) fillColor = dimColors["关键点"];
|
|
|
|
|
+
|
|
|
|
|
+ // 绘制中心节点
|
|
|
|
|
+ const nodeGroup = egoGroup.append("g")
|
|
|
|
|
+ .attr("class", "ego-node-group")
|
|
|
|
|
+ .attr("transform", "translate(0, 0)");
|
|
|
|
|
+
|
|
|
|
|
+ nodeGroup.append("circle")
|
|
|
|
|
+ .attr("r", 22)
|
|
|
|
|
+ .attr("fill", fillColor)
|
|
|
|
|
+ .attr("stroke", "#fff")
|
|
|
|
|
+ .attr("stroke-width", 3)
|
|
|
|
|
+ .attr("cursor", "pointer")
|
|
|
|
|
+ .style("filter", "drop-shadow(0 2px 4px rgba(0,0,0,0.3))");
|
|
|
|
|
+
|
|
|
|
|
+ nodeGroup.append("text")
|
|
|
|
|
+ .attr("dy", "0.35em")
|
|
|
|
|
+ .attr("text-anchor", "middle")
|
|
|
|
|
+ .attr("fill", "#fff")
|
|
|
|
|
+ .attr("font-size", "10px")
|
|
|
|
|
+ .attr("font-weight", "bold")
|
|
|
|
|
+ .attr("pointer-events", "none")
|
|
|
|
|
+ .text(centerNode.name.length > 4 ? centerNode.name.slice(0, 4) + "..." : centerNode.name);
|
|
|
|
|
|
|
|
- // 如果没有相关节点,显示提示
|
|
|
|
|
- if (nodes.length <= 1) {{
|
|
|
|
|
|
|
+ // 显示提示文字
|
|
|
egoGroup.append("text")
|
|
egoGroup.append("text")
|
|
|
|
|
+ .attr("y", 50)
|
|
|
.attr("text-anchor", "middle")
|
|
.attr("text-anchor", "middle")
|
|
|
.attr("fill", "rgba(255,255,255,0.4)")
|
|
.attr("fill", "rgba(255,255,255,0.4)")
|
|
|
- .attr("font-size", "11px")
|
|
|
|
|
- .text("该节点没有相关边");
|
|
|
|
|
|
|
+ .attr("font-size", "10px")
|
|
|
|
|
+ .text("无相关边");
|
|
|
return;
|
|
return;
|
|
|
}}
|
|
}}
|
|
|
|
|
|
|
|
// 边类型颜色(统一用实线)
|
|
// 边类型颜色(统一用实线)
|
|
|
const edgeColors = {{
|
|
const edgeColors = {{
|
|
|
"属于": "#9b59b6", // 紫色 - 层级关系
|
|
"属于": "#9b59b6", // 紫色 - 层级关系
|
|
|
|
|
+ "包含": "#ffb6c1", // 淡粉 - 分类包含标签(向下)
|
|
|
"分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
|
|
"分类共现(跨点)": "#2ecc71", // 绿色 - 跨帖子分类共现
|
|
|
"分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
|
|
"分类共现(点内)": "#3498db", // 蓝色 - 同帖子分类共现
|
|
|
"标签共现": "#f39c12" // 橙色 - 标签共现
|
|
"标签共现": "#f39c12" // 橙色 - 标签共现
|
|
@@ -2814,14 +3221,15 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
|
|
|
|
|
currentEgoSimulation = simulation;
|
|
currentEgoSimulation = simulation;
|
|
|
|
|
|
|
|
- // 绘制边(统一用实线)
|
|
|
|
|
|
|
+ // 绘制边(用曲线支持多边)
|
|
|
const link = egoGroup.selectAll(".ego-edge")
|
|
const link = egoGroup.selectAll(".ego-edge")
|
|
|
.data(links)
|
|
.data(links)
|
|
|
- .join("line")
|
|
|
|
|
|
|
+ .join("path")
|
|
|
.attr("class", "ego-edge")
|
|
.attr("class", "ego-edge")
|
|
|
.attr("stroke", d => edgeColors[d.type] || "#666")
|
|
.attr("stroke", d => edgeColors[d.type] || "#666")
|
|
|
.attr("stroke-width", 1.5)
|
|
.attr("stroke-width", 1.5)
|
|
|
- .attr("stroke-opacity", 0.7);
|
|
|
|
|
|
|
+ .attr("stroke-opacity", 0.7)
|
|
|
|
|
+ .attr("fill", "none");
|
|
|
|
|
|
|
|
// 绘制节点(分类用方形,标签用圆形)
|
|
// 绘制节点(分类用方形,标签用圆形)
|
|
|
const node = egoGroup.selectAll(".ego-node")
|
|
const node = egoGroup.selectAll(".ego-node")
|
|
@@ -2880,14 +3288,33 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
handleEdgeClick(d.source.id, d.target.id, d.type);
|
|
handleEdgeClick(d.source.id, d.target.id, d.type);
|
|
|
}});
|
|
}});
|
|
|
|
|
|
|
|
|
|
+ // 生成曲线路径的函数
|
|
|
|
|
+ function linkPath(d) {{
|
|
|
|
|
+ const sx = d.source.x, sy = d.source.y;
|
|
|
|
|
+ const tx = d.target.x, ty = d.target.y;
|
|
|
|
|
+
|
|
|
|
|
+ if (d.curvature === 0) {{
|
|
|
|
|
+ // 直线
|
|
|
|
|
+ return `M${{sx}},${{sy}}L${{tx}},${{ty}}`;
|
|
|
|
|
+ }} else {{
|
|
|
|
|
+ // 曲线:计算控制点
|
|
|
|
|
+ const dx = tx - sx, dy = ty - sy;
|
|
|
|
|
+ const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
|
+ // 控制点垂直于连线方向偏移
|
|
|
|
|
+ const mx = (sx + tx) / 2;
|
|
|
|
|
+ const my = (sy + ty) / 2;
|
|
|
|
|
+ const offset = d.curvature * dist * 0.5;
|
|
|
|
|
+ // 垂直方向单位向量
|
|
|
|
|
+ const nx = -dy / dist, ny = dx / dist;
|
|
|
|
|
+ const cx = mx + nx * offset;
|
|
|
|
|
+ const cy = my + ny * offset;
|
|
|
|
|
+ return `M${{sx}},${{sy}}Q${{cx}},${{cy}} ${{tx}},${{ty}}`;
|
|
|
|
|
+ }}
|
|
|
|
|
+ }}
|
|
|
|
|
+
|
|
|
// 更新位置
|
|
// 更新位置
|
|
|
simulation.on("tick", () => {{
|
|
simulation.on("tick", () => {{
|
|
|
- link
|
|
|
|
|
- .attr("x1", d => d.source.x)
|
|
|
|
|
- .attr("y1", d => d.source.y)
|
|
|
|
|
- .attr("x2", d => d.target.x)
|
|
|
|
|
- .attr("y2", d => d.target.y);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ link.attr("d", linkPath);
|
|
|
node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
|
|
node.attr("transform", d => `translate(${{d.x}},${{d.y}})`);
|
|
|
}});
|
|
}});
|
|
|
}}
|
|
}}
|
|
@@ -2931,6 +3358,7 @@ HTML_TEMPLATE = '''<!DOCTYPE html>
|
|
|
// 边类型颜色
|
|
// 边类型颜色
|
|
|
const edgeColors = {{
|
|
const edgeColors = {{
|
|
|
"属于": "#9b59b6",
|
|
"属于": "#9b59b6",
|
|
|
|
|
+ "包含": "#ffb6c1",
|
|
|
"分类共现(跨点)": "#2ecc71",
|
|
"分类共现(跨点)": "#2ecc71",
|
|
|
"分类共现(点内)": "#3498db",
|
|
"分类共现(点内)": "#3498db",
|
|
|
"标签共现": "#f39c12"
|
|
"标签共现": "#f39c12"
|