tree.html 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. {% extends "layout.html" %}
  2. {% block title %}生物遗传图谱 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. #tree-container {
  6. width: 100%;
  7. height: 700px;
  8. background: white;
  9. border: 1px solid #e9ecef;
  10. border-radius: 8px;
  11. margin-top: 10px;
  12. box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
  13. position: relative;
  14. overflow: auto;
  15. }
  16. .node rect, .node circle {
  17. stroke-width: 2px;
  18. filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
  19. }
  20. .node text { font: 13px 'Microsoft YaHei', sans-serif; }
  21. .node .node-name { font-size: 13px; font-weight: 500; fill: #334155; }
  22. /* 样图一致:较粗的细实线、浅灰蓝色,显得专业 */
  23. .link { fill: none; stroke: #94A3B8; stroke-width: 2px; stroke-linejoin: round; }
  24. /* 关系标签文字,取消白色粗描边,因为已有胶囊形背景 */
  25. .link-label { font-size: 11px; fill: #64748B; font-weight: 500; }
  26. .link-label-sibling { fill: #64748B; }
  27. /* 男女形状与颜色区分:男性方形(蓝),女性圆形(粉红),添加轻微圆角与投影 */
  28. .node-male rect { stroke: #3B82F6; fill: #EFF6FF; rx: 8px; ry: 8px; }
  29. .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
  30. /* 未知性别默认 */
  31. .node-leaf circle, .node-internal circle { stroke: #94A3B8; fill: #F8FAFC; }
  32. .node-male circle { stroke: none; fill: none; } /* 清除可能的干扰 */
  33. /* 样图一致:全部细实线 */
  34. .link-parent-child { stroke: #333; stroke-dasharray: none; }
  35. .link-spouse { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  36. .link-sibling { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  37. /* 右键菜单样式 */
  38. .context-menu {
  39. position: absolute;
  40. display: none;
  41. background: white;
  42. border: 1px solid #ccc;
  43. box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
  44. z-index: 1000;
  45. border-radius: 4px;
  46. padding: 5px 0;
  47. min-width: 120px;
  48. }
  49. .context-menu-item {
  50. padding: 8px 15px;
  51. cursor: pointer;
  52. font-size: 14px;
  53. color: #333;
  54. }
  55. .context-menu-item:hover {
  56. background-color: #f8f9fa;
  57. color: #0d6efd;
  58. }
  59. .context-menu-item i {
  60. margin-right: 8px;
  61. }
  62. </style>
  63. {% endblock %}
  64. {% block content %}
  65. <div class="d-flex justify-content-between align-items-center mb-3">
  66. <h2><i class="bi bi-diagram-3"></i> 家谱关系树状图</h2>
  67. <!-- <p class="text-muted mb-0">基于父子/母子关系的生物遗传图谱</p> -->
  68. </div>
  69. <div class="alert alert-light border small py-2">
  70. <i class="bi bi-info-circle me-1"></i> 提示:图中按生物遗传图谱格式展示。支持拖拽建立关系,右键点击成员可查看、编辑或新增。
  71. </div>
  72. <div id="tree-container">
  73. <!-- 右键菜单 -->
  74. <div id="contextMenu" class="context-menu">
  75. <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
  76. <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
  77. <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
  78. </div>
  79. </div>
  80. <!-- 关系选择弹窗 -->
  81. <div class="modal fade" id="relationModal" tabindex="-1">
  82. <div class="modal-dialog modal-sm">
  83. <div class="modal-content">
  84. <div class="modal-header">
  85. <h5 class="modal-title">建立关系</h5>
  86. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  87. </div>
  88. <div class="modal-body">
  89. <p id="relationInfo" class="small mb-3"></p>
  90. <input type="hidden" id="sourceMid">
  91. <input type="hidden" id="targetMid">
  92. <div class="mb-3">
  93. <label class="form-label small">关系类型</label>
  94. <select id="relType" class="form-select form-select-sm">
  95. <option value="1">是其 儿子/女儿</option>
  96. <option value="10">是其 妻子/丈夫</option>
  97. <option value="11">是其 兄弟</option>
  98. <option value="12">是其 姐妹</option>
  99. </select>
  100. </div>
  101. <div class="mb-3">
  102. <label class="form-label small">子类型</label>
  103. <select id="subRelType" class="form-select form-select-sm">
  104. <option value="0">亲生/正妻</option>
  105. <option value="1">养子/女</option>
  106. <option value="2">过继</option>
  107. </select>
  108. </div>
  109. </div>
  110. <div class="modal-footer">
  111. <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
  112. <button type="button" class="btn btn-sm btn-primary" onclick="saveRelation()">保存关系</button>
  113. </div>
  114. </div>
  115. </div>
  116. </div>
  117. {% endblock %}
  118. {% block extra_js %}
  119. <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
  120. <script>
  121. (function() {
  122. if (typeof d3 === 'undefined') {
  123. var container = document.getElementById('tree-container');
  124. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  125. }
  126. })();
  127. let currentData = null;
  128. let dragSource = null;
  129. let dragTarget = null;
  130. let selectedMid = null; // 当前选中的成员 ID
  131. const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
  132. const contextMenu = document.getElementById('contextMenu');
  133. // 隐藏右键菜单
  134. window.addEventListener('click', () => {
  135. contextMenu.style.display = 'none';
  136. });
  137. // 处理菜单点击
  138. function menuAction(type) {
  139. if (!selectedMid && type !== 'add') return;
  140. switch(type) {
  141. case 'detail':
  142. window.location.href = `/manager/member_detail/${selectedMid}`;
  143. break;
  144. case 'edit':
  145. window.location.href = `/manager/edit_member/${selectedMid}`;
  146. break;
  147. case 'add':
  148. window.location.href = '/manager/add_member';
  149. break;
  150. }
  151. }
  152. // 获取数据并渲染
  153. function loadTree() {
  154. if (typeof d3 === 'undefined') return;
  155. fetch('/manager/api/tree_data')
  156. .then(response => response.json())
  157. .then(data => {
  158. currentData = data;
  159. renderTree(data);
  160. });
  161. }
  162. if (typeof d3 !== 'undefined') loadTree();
  163. function renderTree(data) {
  164. const container = document.getElementById('tree-container');
  165. container.innerHTML = '';
  166. container.appendChild(contextMenu);
  167. try {
  168. const { members, relations } = data;
  169. if (!members || members.length === 0) {
  170. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted">暂无成员数据,无法生成关系图。</div>';
  171. return;
  172. }
  173. const nodes = members.map(m => ({ id: m.id, name: m.name, simplified_name: m.simplified_name, sex: m.sex }));
  174. const hierarchicalLinks = relations.filter(r => r.relation_type === 1 || r.relation_type === 2)
  175. .map(r => ({ source: r.parent_mid, target: r.child_mid }));
  176. const spouseLinks = relations.filter(r => r.relation_type === 10);
  177. const otherLinks = relations.filter(r => r.relation_type >= 11);
  178. const childIds = new Set(hierarchicalLinks.map(l => l.target));
  179. const allSpouseIds = new Set(spouseLinks.map(l => l.child_mid));
  180. const roots = nodes.filter(n => !childIds.has(n.id) && !allSpouseIds.has(n.id));
  181. function buildHierarchy(nodeId, processedNodes = new Set()) {
  182. const node = nodes.find(n => n.id === nodeId);
  183. if (!node || processedNodes.has(nodeId)) return null;
  184. processedNodes.add(nodeId);
  185. const children = hierarchicalLinks.filter(l => l.source === nodeId)
  186. .map(l => buildHierarchy(l.target, processedNodes))
  187. .filter(c => c !== null);
  188. const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
  189. .map(l => {
  190. const spouseId = l.child_mid;
  191. if (!childIds.has(spouseId)) {
  192. const sNode = nodes.find(n => n.id === spouseId);
  193. if (sNode && !processedNodes.has(spouseId)) {
  194. processedNodes.add(spouseId);
  195. return { id: sNode.id, name: sNode.name, simplified_name: sNode.simplified_name, sex: sNode.sex, isSpouseNode: true, children: [] };
  196. }
  197. }
  198. return null;
  199. })
  200. .filter(s => s !== null);
  201. return { id: node.id, name: node.name, simplified_name: node.simplified_name, sex: node.sex, children: spouses.concat(children) };
  202. }
  203. let treeData;
  204. if (roots.length > 1) {
  205. treeData = { name: "家谱根源", children: roots.map(root => buildHierarchy(root.id)).filter(r => r !== null) };
  206. } else if (roots.length === 1) {
  207. treeData = buildHierarchy(roots[0].id);
  208. } else {
  209. treeData = buildHierarchy(nodes[0].id);
  210. }
  211. const margin = {top: 80, right: 60, bottom: 80, left: 60};
  212. const containerWidth = document.getElementById('tree-container').offsetWidth;
  213. let rootNode = d3.hierarchy(treeData, d => (d && d.children) || []);
  214. // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
  215. const nodeWidth = 90; // 基础宽度
  216. const nodeHeight = 220; // 基础高度
  217. const treemap = d3.tree().nodeSize([nodeWidth, nodeHeight]).separation((a, b) => {
  218. const aSp = a.data && a.data.isSpouseNode;
  219. const bSp = b.data && b.data.isSpouseNode;
  220. if (aSp || bSp) return 1.8;
  221. return a.parent === b.parent ? 1.6 : 2.5;
  222. });
  223. let nodesHier = treemap(rootNode);
  224. // 样图一致:配偶与当事人同代、同一水平线并排,且水平至少间隔 spouseGap
  225. const spouseGap = 110;
  226. nodesHier.descendants().forEach(d => {
  227. if (d.data && d.data.isSpouseNode && d.parent) {
  228. d.y = d.parent.y;
  229. const dx = d.x - d.parent.x;
  230. if (Math.abs(dx) < spouseGap) d.x = d.parent.x + (dx >= 0 ? spouseGap : -spouseGap);
  231. }
  232. });
  233. // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
  234. let x0 = Infinity;
  235. let x1 = -Infinity;
  236. let y1 = -Infinity;
  237. nodesHier.descendants().forEach(d => {
  238. if (d.x < x0) x0 = d.x;
  239. if (d.x > x1) x1 = d.x;
  240. if (d.y > y1) y1 = d.y;
  241. });
  242. const svgWidth = Math.max(containerWidth, x1 - x0 + margin.left + margin.right);
  243. const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
  244. // 偏移量使得最小的 x 在 margin.left 位置,且如果内容较少则居中
  245. const offsetX = Math.max(margin.left, (containerWidth - (x1 - x0)) / 2 - x0);
  246. const svg = d3.select("#tree-container").append("svg")
  247. .attr("width", svgWidth)
  248. .attr("height", svgHeight)
  249. .append("g")
  250. .attr("transform", `translate(${offsetX},${margin.top})`);
  251. // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
  252. const circleR = 20;
  253. // 辅助函数:绘制精致的徽章式关系标签
  254. function addBadge(g, x, y, text) {
  255. const group = g.append("g").attr("transform", `translate(${x},${y})`);
  256. group.append("rect")
  257. .attr("x", -20).attr("y", -10)
  258. .attr("width", 40).attr("height", 20)
  259. .attr("rx", 10).attr("ry", 10) // 胶囊形状
  260. .attr("fill", "#fff")
  261. .attr("stroke", "#CBD5E1").attr("stroke-width", 1.2);
  262. group.append("text")
  263. .attr("class", "link-label")
  264. .attr("x", 0).attr("y", 0).attr("dy", "0.32em")
  265. .attr("text-anchor", "middle")
  266. .text(text);
  267. }
  268. // 连线:第二层连线设计(U型配偶线 + 亲子水平线)
  269. nodesHier.descendants().forEach(node => {
  270. if (!node.children || node.children.length === 0) return;
  271. const realChildren = (node.children || []).filter(c => c.data && !c.data.isSpouseNode);
  272. const spouses = (node.children || []).filter(c => c.data && c.data.isSpouseNode);
  273. const spouseY = node.y + 65; // 第一层:夫妻下方 U 型横线高度
  274. const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
  275. // 夫妻关系:U型连线(增加第二层连线,主线在连出来)
  276. if (spouses.length > 0) {
  277. const x1 = Math.min(node.x, spouses[0].x), x2 = Math.max(node.x, spouses[0].x);
  278. const g = svg.append("g").attr("class", "link-group");
  279. const pathD = `M${num(node.x)},${num(node.y + circleR)} L${num(node.x)},${num(spouseY)} M${num(spouses[0].x)},${num(node.y + circleR)} L${num(spouses[0].x)},${num(spouseY)} M${num(x1)},${num(spouseY)} L${num(x2)},${num(spouseY)}`;
  280. g.append("path").attr("class", "link link-spouse").attr("d", pathD);
  281. addBadge(g, (x1 + x2) / 2, spouseY, "配偶");
  282. }
  283. if (realChildren.length === 0) return;
  284. const childrenY = realChildren[0].y;
  285. const minChildX = d3.min(realChildren, c => c.x);
  286. const maxChildX = d3.max(realChildren, c => c.x);
  287. const startX = spouses.length > 0 ? (node.x + spouses[0].x) / 2 : node.x;
  288. const startY = spouses.length > 0 ? spouseY : node.y + circleR;
  289. const sibsY = childrenY - 55; // 第二层:子女上方的水平分支线(拉高一点,给徽章留足空间)
  290. const g = svg.append("g").attr("class", "link-group");
  291. // 主线:从配偶U型线中点连到子女水平线
  292. let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} L${num(minChildX)},${num(sibsY)} M${num(startX)},${num(sibsY)} L${num(maxChildX)},${num(sibsY)}`;
  293. // 短竖线:连到每个子女顶部
  294. realChildren.forEach(child => {
  295. pathD += ` M${num(child.x)},${num(sibsY)} L${num(child.x)},${num(child.y) - circleR}`;
  296. });
  297. g.append("path").attr("class", "link link-parent-child").attr("d", pathD);
  298. // 去除家谱根源到第一层的关系展示;下层亲子关系标记使用徽章式标签放在短竖线上,一目了然
  299. const isRootToFirst = !node.parent || !(node.data && node.data.id);
  300. if (!isRootToFirst && node.data) {
  301. realChildren.forEach(child => {
  302. const pSex = node.data.sex;
  303. const cSex = child.data && child.data.sex;
  304. let label = "亲子";
  305. if (pSex === 1) label = cSex === 1 ? "父子" : "父女";
  306. else if (pSex === 2) label = cSex === 1 ? "母子" : "母女";
  307. const childLinkMidY = (sibsY + child.y - circleR) / 2;
  308. addBadge(g, child.x, childLinkMidY, label);
  309. });
  310. }
  311. });
  312. // 兄弟/姐妹:上方 U 型连线避免穿透节点
  313. const idToPos = {};
  314. nodesHier.descendants().forEach(d => { if (d.data.id) idToPos[d.data.id] = { x: d.x, y: d.y }; });
  315. otherLinks.forEach(rel => {
  316. const s = idToPos[rel.parent_mid], t = idToPos[rel.child_mid];
  317. if (s && t) {
  318. const x1 = Math.min(s.x, t.x), x2 = Math.max(s.x, t.x);
  319. const y = s.y - circleR - 25; // 兄弟线在节点上方
  320. const g = svg.append("g");
  321. const pathD = `M${x1},${s.y - circleR} L${x1},${y} L${x2},${y} L${x2},${t.y - circleR}`;
  322. g.append("path").attr("class", "link link-sibling").attr("d", pathD);
  323. addBadge(g, (x1 + x2) / 2, y, rel.relation_type === 11 ? "兄弟" : "姐妹");
  324. }
  325. });
  326. const node = svg.selectAll(".node")
  327. .data(nodesHier.descendants())
  328. .enter().append("g")
  329. .attr("class", d => {
  330. let cls = "node" + (d.children ? " node--internal" : " node--leaf");
  331. if (d.data.sex === 1) cls += " node-male";
  332. else if (d.data.sex === 2) cls += " node-female";
  333. return cls;
  334. })
  335. .attr("transform", d => `translate(${d.x},${d.y})`)
  336. .on("contextmenu", function(event, d) {
  337. if (!d.data.id) return;
  338. event.preventDefault();
  339. selectedMid = d.data.id;
  340. const containerRect = document.getElementById('tree-container').getBoundingClientRect();
  341. contextMenu.style.display = 'block';
  342. contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
  343. contextMenu.style.top = (event.clientY - containerRect.top) + 'px';
  344. })
  345. .call(d3.drag()
  346. .on("start", dragstarted)
  347. .on("drag", dragged)
  348. .on("end", dragended));
  349. // 图形:男性方形,女性圆形,更符合生物遗传图谱(不再需要性别符号)
  350. node.each(function(d) {
  351. const el = d3.select(this);
  352. if (d.data.sex === 1) { // 男性为方形
  353. el.append("rect")
  354. .attr("x", -circleR).attr("y", -circleR)
  355. .attr("width", circleR * 2).attr("height", circleR * 2)
  356. .style("cursor", "grab");
  357. } else { // 女性为圆形(未知性别默认圆)
  358. el.append("circle")
  359. .attr("r", circleR)
  360. .style("cursor", "grab");
  361. }
  362. });
  363. // 人名:严格在图形下方,与图形底边留出间距,长名截断+悬停显示全名
  364. const nameOffsetY = circleR + 20;
  365. const maxNameLen = 8;
  366. function fullName(d) {
  367. if (!d.data) return '';
  368. return d.data.simplified_name ? `${d.data.name || ''} (${d.data.simplified_name})` : (d.data.name || '');
  369. }
  370. const nameGroup = node.append("g").attr("class", "node-name-wrap").attr("transform", "translate(0, " + nameOffsetY + ")");
  371. nameGroup.each(function(d) {
  372. const g = d3.select(this);
  373. const full = fullName(d);
  374. const disp = full.length <= maxNameLen ? full : full.slice(0, maxNameLen) + '…';
  375. // 允许指针事件以触发 tooltip (title)
  376. const textNode = g.append("text")
  377. .attr("class", "node-name")
  378. .attr("x", 0).attr("y", 0)
  379. .attr("text-anchor", "middle")
  380. .style("pointer-events", "all")
  381. .style("cursor", "default")
  382. .text(disp);
  383. if (full.length > maxNameLen) {
  384. textNode.append("title").text(full);
  385. }
  386. });
  387. function dragstarted(event, d) {
  388. if (!d.data.id) return;
  389. d3.select(this).raise().classed("active", true);
  390. d._currentX = d.x; d._currentY = d.y;
  391. dragSource = d.data;
  392. }
  393. function dragged(event, d) {
  394. d._currentX += event.dx; d._currentY += event.dy;
  395. d3.select(this).attr("transform", `translate(${d._currentX},${d._currentY})`);
  396. }
  397. function dragended(event, d) {
  398. d3.select(this).classed("active", false);
  399. const mouseX = event.sourceEvent.clientX, mouseY = event.sourceEvent.clientY;
  400. let foundNode = null;
  401. svg.selectAll(".node").each(function(nodeData) {
  402. if (!nodeData.data || nodeData.data.id === d.data.id || !nodeData.data.id) return;
  403. const rect = this.getBoundingClientRect();
  404. if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) foundNode = nodeData.data;
  405. });
  406. if (foundNode && foundNode.id) {
  407. dragTarget = foundNode;
  408. document.getElementById('sourceMid').value = dragSource.id;
  409. document.getElementById('targetMid').value = dragTarget.id;
  410. document.getElementById('relationInfo').innerHTML = `确认将 <strong>${dragSource.name}</strong> 设定为 <strong>${dragTarget.name}</strong> 的关系人?`;
  411. relationModal.show();
  412. }
  413. setTimeout(() => { renderTree(currentData); }, 100);
  414. }
  415. } catch (err) {
  416. console.error('renderTree error:', err);
  417. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">关系图渲染出错,请刷新重试。<br>' + (err.message || '') + '</div>';
  418. }
  419. }
  420. function saveRelation() {
  421. const payload = {
  422. source_mid: document.getElementById('sourceMid').value,
  423. target_mid: document.getElementById('targetMid').value,
  424. relation_type: document.getElementById('relType').value,
  425. sub_relation_type: document.getElementById('subRelType').value
  426. };
  427. fetch('/manager/api/save_relation', {
  428. method: 'POST',
  429. headers: { 'Content-Type': 'application/json' },
  430. body: JSON.stringify(payload)
  431. }).then(res => res.json()).then(data => {
  432. if (data.success) { relationModal.hide(); loadTree(); }
  433. else { alert('保存失败: ' + data.message); }
  434. });
  435. }
  436. </script>
  437. {% endblock %}