|
@@ -131,6 +131,27 @@
|
|
|
.context-menu-item i {
|
|
.context-menu-item i {
|
|
|
margin-right: 8px;
|
|
margin-right: 8px;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /* 折叠/展开 +/- 小圆钮 */
|
|
|
|
|
+ .collapse-toggle {
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+ .collapse-toggle circle {
|
|
|
|
|
+ transition: fill 0.2s, transform 0.2s;
|
|
|
|
|
+ }
|
|
|
|
|
+ .collapse-toggle:hover circle {
|
|
|
|
|
+ opacity: 0.85;
|
|
|
|
|
+ }
|
|
|
|
|
+ .collapse-toggle text {
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ user-select: none;
|
|
|
|
|
+ }
|
|
|
|
|
+ /* 节点被折叠时降低透明度以区分 */
|
|
|
|
|
+ .node-collapsed > rect,
|
|
|
|
|
+ .node-collapsed > circle {
|
|
|
|
|
+ opacity: 0.75;
|
|
|
|
|
+ stroke-dasharray: 5, 3;
|
|
|
|
|
+ }
|
|
|
</style>
|
|
</style>
|
|
|
{% endblock %}
|
|
{% endblock %}
|
|
|
|
|
|
|
@@ -144,6 +165,9 @@
|
|
|
<i class="bi bi-search"></i> 搜索
|
|
<i class="bi bi-search"></i> 搜索
|
|
|
</button>
|
|
</button>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <button class="btn btn-outline-secondary btn-sm" onclick="expandAll()" title="展开所有已折叠节点">
|
|
|
|
|
+ <i class="bi bi-arrows-expand"></i> 全部展开
|
|
|
|
|
+ </button>
|
|
|
<a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary btn-sm">
|
|
<a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary btn-sm">
|
|
|
<i class="bi bi-printer"></i> 导出传统吊线图
|
|
<i class="bi bi-printer"></i> 导出传统吊线图
|
|
|
</a>
|
|
</a>
|
|
@@ -166,6 +190,7 @@
|
|
|
<div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
|
|
<div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
|
|
|
<div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
|
|
<div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
|
|
|
<div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
|
|
<div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
|
|
|
|
|
+ <div class="context-menu-item" id="collapseMenuItem" onclick="menuAction('collapse')"><i class="bi bi-arrows-collapse"></i>收起子节点</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -241,6 +266,7 @@
|
|
|
let dragSource = null;
|
|
let dragSource = null;
|
|
|
let dragTarget = null;
|
|
let dragTarget = null;
|
|
|
let selectedMid = null; // 当前选中的成员 ID
|
|
let selectedMid = null; // 当前选中的成员 ID
|
|
|
|
|
+ let collapsedNodes = new Set(); // 已折叠的节点 ID 集合
|
|
|
let zoomScale = 1;
|
|
let zoomScale = 1;
|
|
|
let zoomTransform = d3.zoomIdentity;
|
|
let zoomTransform = d3.zoomIdentity;
|
|
|
let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
|
|
let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
|
|
@@ -252,6 +278,12 @@
|
|
|
contextMenu.style.display = 'none';
|
|
contextMenu.style.display = 'none';
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // 全部展开
|
|
|
|
|
+ function expandAll() {
|
|
|
|
|
+ collapsedNodes.clear();
|
|
|
|
|
+ renderTree(currentData);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 处理菜单点击
|
|
// 处理菜单点击
|
|
|
function menuAction(type) {
|
|
function menuAction(type) {
|
|
|
if (!selectedMid && type !== 'add') return;
|
|
if (!selectedMid && type !== 'add') return;
|
|
@@ -266,6 +298,17 @@
|
|
|
case 'add':
|
|
case 'add':
|
|
|
window.location.href = '/manager/add_member';
|
|
window.location.href = '/manager/add_member';
|
|
|
break;
|
|
break;
|
|
|
|
|
+ case 'collapse': {
|
|
|
|
|
+ const savedTr = zoomTransform;
|
|
|
|
|
+ if (collapsedNodes.has(selectedMid)) {
|
|
|
|
|
+ collapsedNodes.delete(selectedMid);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ collapsedNodes.add(selectedMid);
|
|
|
|
|
+ }
|
|
|
|
|
+ renderTree(currentData);
|
|
|
|
|
+ d3.select("#tree-container svg").call(zoomBehavior.transform, savedTr);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -340,10 +383,15 @@
|
|
|
if (!node || processedNodes.has(nodeId)) return null;
|
|
if (!node || processedNodes.has(nodeId)) return null;
|
|
|
processedNodes.add(nodeId);
|
|
processedNodes.add(nodeId);
|
|
|
|
|
|
|
|
- const children = hierarchicalLinks.filter(l => l.source === nodeId)
|
|
|
|
|
- .map(l => buildHierarchy(l.target, processedNodes))
|
|
|
|
|
- .filter(c => c !== null);
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const childLinks = hierarchicalLinks.filter(l => l.source === nodeId);
|
|
|
|
|
+ const hasHierarchicalChildren = childLinks.length > 0;
|
|
|
|
|
+ const isCollapsed = collapsedNodes.has(nodeId);
|
|
|
|
|
+
|
|
|
|
|
+ // 折叠时不递归子代(但仍保留配偶以维持同层显示)
|
|
|
|
|
+ const children = isCollapsed
|
|
|
|
|
+ ? []
|
|
|
|
|
+ : childLinks.map(l => buildHierarchy(l.target, processedNodes)).filter(c => c !== null);
|
|
|
|
|
+
|
|
|
const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
|
|
const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
|
|
|
.map(l => {
|
|
.map(l => {
|
|
|
const spouseId = l.child_mid;
|
|
const spouseId = l.child_mid;
|
|
@@ -358,7 +406,17 @@
|
|
|
})
|
|
})
|
|
|
.filter(s => s !== null);
|
|
.filter(s => s !== null);
|
|
|
|
|
|
|
|
- return { id: node.id, name: node.name, simplified_name: node.simplified_name, sex: node.sex, children: spouses.concat(children) };
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: node.id,
|
|
|
|
|
+ name: node.name,
|
|
|
|
|
+ simplified_name: node.simplified_name,
|
|
|
|
|
+ sex: node.sex,
|
|
|
|
|
+ adoptedOut: node.adoptedOut,
|
|
|
|
|
+ adoptedOutTarget: node.adoptedOutTarget,
|
|
|
|
|
+ hasHierarchicalChildren,
|
|
|
|
|
+ isCollapsed,
|
|
|
|
|
+ children: spouses.concat(children)
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let treeData;
|
|
let treeData;
|
|
@@ -481,7 +539,8 @@
|
|
|
|
|
|
|
|
// 如果有配偶,子代主线从本人和最右侧配偶的中间引出,否则从自己直接引出
|
|
// 如果有配偶,子代主线从本人和最右侧配偶的中间引出,否则从自己直接引出
|
|
|
const startX = spouses.length > 0 ? (node.x + spouses[spouses.length - 1].x) / 2 : node.x;
|
|
const startX = spouses.length > 0 ? (node.x + spouses[spouses.length - 1].x) / 2 : node.x;
|
|
|
- const startY = node.y + circleR;
|
|
|
|
|
|
|
+ // 若节点有折叠按钮(底部),连线从按钮下方起始
|
|
|
|
|
+ const startY = node.y + circleR + (node.data.hasHierarchicalChildren ? 28 : 0);
|
|
|
|
|
|
|
|
// 将子女横线也相应地下移一些,避免和配偶长名字重叠
|
|
// 将子女横线也相应地下移一些,避免和配偶长名字重叠
|
|
|
const sibsY = childrenY - 60;
|
|
const sibsY = childrenY - 60;
|
|
@@ -546,6 +605,21 @@
|
|
|
if (!d.data.id) return;
|
|
if (!d.data.id) return;
|
|
|
event.preventDefault();
|
|
event.preventDefault();
|
|
|
selectedMid = d.data.id;
|
|
selectedMid = d.data.id;
|
|
|
|
|
+ // 动态更新折叠菜单项文字与可见性
|
|
|
|
|
+ const collapseItem = document.getElementById('collapseMenuItem');
|
|
|
|
|
+ if (d.data.hasHierarchicalChildren) {
|
|
|
|
|
+ collapseItem.style.display = '';
|
|
|
|
|
+ const icon = collapseItem.querySelector('i');
|
|
|
|
|
+ if (collapsedNodes.has(d.data.id)) {
|
|
|
|
|
+ icon.className = 'bi bi-arrows-expand';
|
|
|
|
|
+ collapseItem.childNodes[1].textContent = '展开子节点';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ icon.className = 'bi bi-arrows-collapse';
|
|
|
|
|
+ collapseItem.childNodes[1].textContent = '收起子节点';
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ collapseItem.style.display = 'none';
|
|
|
|
|
+ }
|
|
|
const containerRect = document.getElementById('tree-container').getBoundingClientRect();
|
|
const containerRect = document.getElementById('tree-container').getBoundingClientRect();
|
|
|
contextMenu.style.display = 'block';
|
|
contextMenu.style.display = 'block';
|
|
|
contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
|
|
contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
|
|
@@ -572,7 +646,9 @@
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
|
|
// 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
|
|
|
- const nameOffsetY = circleR + 25; // 增加间距
|
|
|
|
|
|
|
+ // 有折叠按钮的节点多留出按钮空间(按钮中心在 y=circleR,高度约 20px,再留 8px 间距)
|
|
|
|
|
+ // circleR(20) + 按钮中心(12) + 按钮半径(11) + 间距(6) = 49
|
|
|
|
|
+ const nameOffsetY = circleR + 49;
|
|
|
const maxNameLen = 12; // 允许稍微长一点的文字
|
|
const maxNameLen = 12; // 允许稍微长一点的文字
|
|
|
function fullName(d) {
|
|
function fullName(d) {
|
|
|
if (!d.data) return '';
|
|
if (!d.data) return '';
|
|
@@ -601,6 +677,55 @@
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ // 标记折叠节点样式
|
|
|
|
|
+ node.each(function(d) {
|
|
|
|
|
+ if (d.data.isCollapsed) d3.select(this).classed("node-collapsed", true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ── 折叠/展开按钮 ──────────────────────────────────────────────────
|
|
|
|
|
+ // 直接追加到 SVG 坐标层(非节点 g 内部),彻底脱离 drag 事件树,确保可见可点
|
|
|
|
|
+ nodesHier.descendants().forEach(function(nd) {
|
|
|
|
|
+ if (!nd.data.id || !nd.data.hasHierarchicalChildren) return;
|
|
|
|
|
+
|
|
|
|
|
+ const collapsed = nd.data.isCollapsed;
|
|
|
|
|
+ const bx = nd.x;
|
|
|
|
|
+ const by = nd.y + circleR + 14; // 形状底边再往下 14px
|
|
|
|
|
+
|
|
|
|
|
+ const tg = svg.append("g")
|
|
|
|
|
+ .attr("class", "collapse-toggle")
|
|
|
|
|
+ .attr("transform", `translate(${bx},${by})`)
|
|
|
|
|
+ .style("cursor", "pointer");
|
|
|
|
|
+
|
|
|
|
|
+ tg.on("mousedown", function(e) { e.stopPropagation(); });
|
|
|
|
|
+ tg.on("click", function(e) {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ const savedTransform = zoomTransform; // 保存当前视图位置
|
|
|
|
|
+ if (collapsedNodes.has(nd.data.id)) {
|
|
|
|
|
+ collapsedNodes.delete(nd.data.id);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ collapsedNodes.add(nd.data.id);
|
|
|
|
|
+ }
|
|
|
|
|
+ renderTree(currentData);
|
|
|
|
|
+ // 重建后立即还原 zoom 位置,不触发动画
|
|
|
|
|
+ d3.select("#tree-container svg").call(zoomBehavior.transform, savedTransform);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ tg.append("title").text(collapsed ? "展开子节点" : "收起子节点");
|
|
|
|
|
+ // 白色底圆(防止线条透底)
|
|
|
|
|
+ tg.append("circle").attr("r", 11).attr("fill", "white");
|
|
|
|
|
+ // 彩色实心圆:展开=蓝,折叠=绿
|
|
|
|
|
+ tg.append("circle")
|
|
|
|
|
+ .attr("r", 10)
|
|
|
|
|
+ .attr("fill", collapsed ? "#10B981" : "#3B82F6")
|
|
|
|
|
+ .attr("stroke", "white").attr("stroke-width", 1.5);
|
|
|
|
|
+ // 符号文字
|
|
|
|
|
+ tg.append("text")
|
|
|
|
|
+ .attr("text-anchor", "middle").attr("dy", "0.38em")
|
|
|
|
|
+ .attr("fill", "white").attr("font-size", "16px").attr("font-weight", "bold")
|
|
|
|
|
+ .attr("pointer-events", "none")
|
|
|
|
|
+ .text(collapsed ? "+" : "−");
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
function dragstarted(event, d) {
|
|
function dragstarted(event, d) {
|
|
|
if (!d.data.id) return;
|
|
if (!d.data.id) return;
|
|
|
d3.select(this).raise().classed("active", true);
|
|
d3.select(this).raise().classed("active", true);
|