tree.html 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003
  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: hidden;
  15. }
  16. .zoom-controls {
  17. position: absolute;
  18. top: 10px;
  19. right: 10px;
  20. z-index: 100;
  21. background: white;
  22. border-radius: 4px;
  23. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  24. padding: 5px;
  25. }
  26. .zoom-btn {
  27. display: block;
  28. width: 30px;
  29. height: 30px;
  30. margin: 5px 0;
  31. border: 1px solid #ddd;
  32. border-radius: 4px;
  33. background: white;
  34. cursor: pointer;
  35. display: flex;
  36. align-items: center;
  37. justify-content: center;
  38. }
  39. .zoom-btn:hover {
  40. background: #f8f9fa;
  41. }
  42. #tree-container::-webkit-scrollbar {
  43. width: 8px;
  44. height: 8px;
  45. }
  46. #tree-container::-webkit-scrollbar-track {
  47. background: #f1f1f1;
  48. border-radius: 4px;
  49. }
  50. #tree-container::-webkit-scrollbar-thumb {
  51. background: #c1c1c1;
  52. border-radius: 4px;
  53. }
  54. #tree-container::-webkit-scrollbar-thumb:hover {
  55. background: #a1a1a1;
  56. }
  57. .node rect, .node circle {
  58. stroke-width: 2px;
  59. filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
  60. }
  61. .node text { font: 13px 'Microsoft YaHei', sans-serif; }
  62. .node .node-name {
  63. font-size: 13px;
  64. font-weight: 500;
  65. fill: #334155;
  66. stroke: #fff;
  67. stroke-width: 4px;
  68. paint-order: stroke;
  69. stroke-linejoin: round;
  70. }
  71. /* 样图一致:较粗的细实线、浅灰蓝色,显得专业 */
  72. .link { fill: none; stroke: #94A3B8; stroke-width: 2px; stroke-linejoin: round; }
  73. /* 关系标签文字,取消白色粗描边,因为已有胶囊形背景 */
  74. .link-label { font-size: 11px; fill: #64748B; font-weight: 500; }
  75. .link-label-sibling { fill: #64748B; }
  76. /* 男女形状与颜色区分:男性方形(蓝),女性圆形(粉红),添加轻微圆角与投影 */
  77. .node-male rect { stroke: #3B82F6; fill: #EFF6FF; rx: 8px; ry: 8px; }
  78. /* 出继节点样式:同色虚线框,仅边框样式区别,颜色与普通节点保持一致 */
  79. .node-adopted-out rect, .node-adopted-out circle {
  80. stroke-dasharray: 6, 4 !important;
  81. }
  82. .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
  83. /* 未知性别默认 */
  84. .node-leaf circle, .node-internal circle { stroke: #94A3B8; fill: #F8FAFC; }
  85. .node-male circle { stroke: none; fill: none; } /* 清除可能的干扰 */
  86. /* 样图一致:全部细实线 */
  87. .link-parent-child { stroke: #333; stroke-dasharray: none; }
  88. .link-spouse { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  89. .link-sibling { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  90. /* 右键菜单样式 */
  91. .context-menu {
  92. position: absolute;
  93. display: none;
  94. background: white;
  95. border: 1px solid #ccc;
  96. box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
  97. z-index: 1000;
  98. border-radius: 4px;
  99. padding: 5px 0;
  100. min-width: 120px;
  101. }
  102. .context-menu-item {
  103. padding: 8px 15px;
  104. cursor: pointer;
  105. font-size: 14px;
  106. color: #333;
  107. }
  108. .context-menu-item:hover {
  109. background-color: #f8f9fa;
  110. color: #0d6efd;
  111. }
  112. .context-menu-item i {
  113. margin-right: 8px;
  114. }
  115. /* 折叠/展开 +/- 小圆钮 */
  116. .collapse-toggle {
  117. cursor: pointer;
  118. }
  119. .collapse-toggle circle {
  120. transition: fill 0.2s, transform 0.2s;
  121. }
  122. .collapse-toggle:hover circle {
  123. opacity: 0.85;
  124. }
  125. .collapse-toggle text {
  126. pointer-events: none;
  127. user-select: none;
  128. }
  129. /* 节点被折叠时降低透明度以区分 */
  130. .node-collapsed > rect,
  131. .node-collapsed > circle {
  132. opacity: 0.75;
  133. stroke-dasharray: 5, 3;
  134. }
  135. </style>
  136. {% endblock %}
  137. {% block content %}
  138. <div class="d-flex justify-content-between align-items-center mb-3">
  139. <h2><i class="bi bi-diagram-3"></i> 家谱关系树状图</h2>
  140. <div class="d-flex gap-2">
  141. <div class="input-group" style="width: 300px;">
  142. <input type="text" id="memberSearch" class="form-control form-control-sm" placeholder="输入成员名字搜索">
  143. <button class="btn btn-sm btn-primary" onclick="searchMember()">
  144. <i class="bi bi-search"></i> 搜索
  145. </button>
  146. </div>
  147. <button class="btn btn-outline-secondary btn-sm" onclick="expandAll()" title="展开所有已折叠节点">
  148. <i class="bi bi-arrows-expand"></i> 全部展开
  149. </button>
  150. <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary btn-sm">
  151. <i class="bi bi-printer"></i> 导出传统吊线图
  152. </a>
  153. </div>
  154. </div>
  155. <div class="alert alert-light border small py-2">
  156. <i class="bi bi-info-circle me-1"></i> 提示:图中按家谱世系树状图格式展示。支持拖拽建立关系,右键点击成员可查看、编辑或新增。
  157. </div>
  158. <div id="tree-container">
  159. <!-- 缩放控制 -->
  160. <div class="zoom-controls">
  161. <button class="zoom-btn" onclick="zoomIn()"><i class="bi bi-plus"></i></button>
  162. <button class="zoom-btn" onclick="zoomOut()"><i class="bi bi-dash"></i></button>
  163. <button class="zoom-btn" onclick="zoomReset()"><i class="bi bi-arrow-counterclockwise"></i></button>
  164. </div>
  165. <!-- 右键菜单 -->
  166. <div id="contextMenu" class="context-menu">
  167. <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
  168. <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
  169. <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
  170. <div class="context-menu-item" id="collapseMenuItem" onclick="menuAction('collapse')"><i class="bi bi-arrows-collapse"></i>收起子节点</div>
  171. </div>
  172. </div>
  173. <!-- 关系选择弹窗 -->
  174. <div class="modal fade" id="relationModal" tabindex="-1">
  175. <div class="modal-dialog modal-sm">
  176. <div class="modal-content">
  177. <div class="modal-header">
  178. <h5 class="modal-title">建立关系</h5>
  179. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  180. </div>
  181. <div class="modal-body">
  182. <p id="relationInfo" class="small mb-3"></p>
  183. <input type="hidden" id="sourceMid">
  184. <input type="hidden" id="targetMid">
  185. <div class="mb-3">
  186. <label class="form-label small">关系类型</label>
  187. <select id="relType" class="form-select form-select-sm">
  188. <option value="1">是其 儿子/女儿</option>
  189. <option value="10">是其 妻子/丈夫</option>
  190. <option value="11">是其 兄弟</option>
  191. <option value="12">是其 姐妹</option>
  192. </select>
  193. </div>
  194. <div class="mb-3">
  195. <label class="form-label small">子类型</label>
  196. <select id="subRelType" class="form-select form-select-sm">
  197. <option value="0">亲生/正妻</option>
  198. <option value="1">养子/女</option>
  199. <option value="2">过继</option>
  200. </select>
  201. </div>
  202. </div>
  203. <div class="modal-footer">
  204. <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
  205. <button type="button" class="btn btn-sm btn-primary" onclick="saveRelation()">保存关系</button>
  206. </div>
  207. </div>
  208. </div>
  209. </div>
  210. {% endblock %}
  211. {% block extra_js %}
  212. <!-- 优先使用本地 D3.js -->
  213. <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
  214. <!-- fallback to CDN if local fails -->
  215. <script>
  216. if (typeof d3 === 'undefined') {
  217. var script = document.createElement('script');
  218. script.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
  219. script.onload = function() {
  220. if (typeof d3 !== 'undefined') {
  221. loadTree();
  222. } else {
  223. var container = document.getElementById('tree-container');
  224. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  225. }
  226. };
  227. script.onerror = function() {
  228. var container = document.getElementById('tree-container');
  229. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  230. };
  231. document.head.appendChild(script);
  232. }
  233. </script>
  234. <script>
  235. (function() {
  236. if (typeof d3 === 'undefined') {
  237. // CDN will handle loading and initialization
  238. }
  239. })();
  240. let currentData = null;
  241. let dragSource = null;
  242. let dragTarget = null;
  243. let selectedMid = null; // 当前选中的成员 ID
  244. let collapsedNodes = new Set(); // 已折叠的节点 ID 集合
  245. let zoomScale = 1;
  246. let zoomTransform = d3.zoomIdentity;
  247. let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
  248. const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
  249. const contextMenu = document.getElementById('contextMenu');
  250. // 隐藏右键菜单
  251. window.addEventListener('click', () => {
  252. contextMenu.style.display = 'none';
  253. });
  254. // 全部展开
  255. function expandAll() {
  256. collapsedNodes.clear();
  257. renderTree(currentData);
  258. }
  259. // 处理菜单点击
  260. function menuAction(type) {
  261. if (!selectedMid && type !== 'add') return;
  262. switch(type) {
  263. case 'detail':
  264. window.location.href = `/manager/member_detail/${selectedMid}`;
  265. break;
  266. case 'edit':
  267. window.location.href = `/manager/edit_member/${selectedMid}`;
  268. break;
  269. case 'add':
  270. window.location.href = '/manager/add_member';
  271. break;
  272. case 'collapse': {
  273. const savedTr = zoomTransform;
  274. if (collapsedNodes.has(selectedMid)) {
  275. collapsedNodes.delete(selectedMid);
  276. } else {
  277. collapsedNodes.add(selectedMid);
  278. }
  279. renderTree(currentData);
  280. d3.select("#tree-container svg").call(zoomBehavior.transform, savedTr);
  281. break;
  282. }
  283. }
  284. }
  285. // 获取数据并渲染
  286. function loadTree() {
  287. if (typeof d3 === 'undefined') return;
  288. fetch('/manager/api/tree_data')
  289. .then(response => response.json())
  290. .then(data => {
  291. currentData = data;
  292. renderTree(data);
  293. });
  294. }
  295. if (typeof d3 !== 'undefined') loadTree();
  296. function renderTree(data) {
  297. const container = document.getElementById('tree-container');
  298. container.innerHTML = '';
  299. container.appendChild(contextMenu);
  300. try {
  301. const { members, relations } = data;
  302. if (!members || members.length === 0) {
  303. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted">暂无成员数据,无法生成关系图。</div>';
  304. return;
  305. }
  306. // 构建节点,保留所有关系信息
  307. const nodes = members.map(m => ({ id: m.id, name: m.name, simplified_name: m.simplified_name, sex: m.sex, adoptedOut: false, adoptedOutTarget: null }));
  308. // 获取所有父子关系(包括出继/入继)
  309. const allHierarchicalRelations = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
  310. // 对于出继的子女,只要有 sub_relation_type===2 的关系即标记;同时尝试找到入继目标名称
  311. const adoptedOutMap = new Map();
  312. allHierarchicalRelations.forEach(r => {
  313. if (r.sub_relation_type === 2) { // 出继
  314. // 即便未找到入继关系也先标记(target 置 null)
  315. if (!adoptedOutMap.has(r.child_mid)) {
  316. adoptedOutMap.set(r.child_mid, null);
  317. }
  318. // 尝试找到对应的入继关系,获取养父名
  319. const targetRelation = allHierarchicalRelations.find(r2 => r2.child_mid === r.child_mid && r2.sub_relation_type === 3);
  320. if (targetRelation) {
  321. const targetMember = members.find(m => m.id === targetRelation.parent_mid);
  322. if (targetMember) {
  323. adoptedOutMap.set(r.child_mid, targetMember.name);
  324. }
  325. }
  326. }
  327. });
  328. // 更新节点的出继标记
  329. nodes.forEach(node => {
  330. if (adoptedOutMap.has(node.id)) {
  331. node.adoptedOut = true;
  332. node.adoptedOutTarget = adoptedOutMap.get(node.id);
  333. }
  334. });
  335. // 保留所有父子关系(包括出继),用于构建树
  336. const hierarchicalLinks = allHierarchicalRelations.map(r => ({
  337. source: r.parent_mid,
  338. target: r.child_mid,
  339. sub_relation_type: r.sub_relation_type
  340. }));
  341. // ── 推断缺失的入继关系 ──────────────────────────────────────────────
  342. // 若同一亲生父的多个出继子女共享同一已知养父,则为缺失入继记录的子女补全推断链接
  343. {
  344. // 按亲生父分组:parentId → [childId, ...](仅 sub=2 的出继子女)
  345. const outByParent = new Map();
  346. allHierarchicalRelations.forEach(r => {
  347. if (r.sub_relation_type === 2) {
  348. if (!outByParent.has(r.parent_mid)) outByParent.set(r.parent_mid, []);
  349. outByParent.get(r.parent_mid).push(r.child_mid);
  350. }
  351. });
  352. outByParent.forEach((childMids, _parentId) => {
  353. // 收集已知养父 ID 集合
  354. const knownTargetIds = new Set();
  355. childMids.forEach(childId => {
  356. const link = hierarchicalLinks.find(l => l.target === childId && l.sub_relation_type === 3);
  357. if (link) knownTargetIds.add(link.source);
  358. });
  359. // 只有唯一确定的养父时才推断(避免歧义)
  360. if (knownTargetIds.size === 1) {
  361. const adoptiveParentId = [...knownTargetIds][0];
  362. childMids.forEach(childId => {
  363. const exists = hierarchicalLinks.some(l => l.source === adoptiveParentId && l.target === childId && l.sub_relation_type === 3);
  364. if (!exists) {
  365. hierarchicalLinks.push({ source: adoptiveParentId, target: childId, sub_relation_type: 3 });
  366. // 同步更新 adoptedOutTarget
  367. const targetMember = members.find(m => m.id === adoptiveParentId);
  368. if (targetMember && adoptedOutMap.has(childId) && !adoptedOutMap.get(childId)) {
  369. adoptedOutMap.set(childId, targetMember.name);
  370. }
  371. }
  372. });
  373. }
  374. });
  375. // adoptedOutTarget 同步回 nodes
  376. nodes.forEach(node => {
  377. if (adoptedOutMap.has(node.id)) {
  378. node.adoptedOut = true;
  379. node.adoptedOutTarget = adoptedOutMap.get(node.id);
  380. }
  381. });
  382. }
  383. // ────────────────────────────────────────────────────────────────────
  384. const spouseLinks = relations.filter(r => r.relation_type === 10);
  385. const otherLinks = relations.filter(r => r.relation_type >= 11);
  386. const childIds = new Set(hierarchicalLinks.map(l => l.target));
  387. const allSpouseIds = new Set(spouseLinks.map(l => l.child_mid));
  388. const roots = nodes.filter(n => !childIds.has(n.id) && !allSpouseIds.has(n.id));
  389. function buildHierarchy(nodeId, processedNodes = new Set()) {
  390. const node = nodes.find(n => n.id === nodeId);
  391. if (!node || processedNodes.has(nodeId)) return null;
  392. processedNodes.add(nodeId);
  393. const childLinks = hierarchicalLinks.filter(l => l.source === nodeId);
  394. const hasHierarchicalChildren = childLinks.length > 0;
  395. const isCollapsed = collapsedNodes.has(nodeId);
  396. // 折叠时不递归子代(但仍保留配偶以维持同层显示)
  397. const children = isCollapsed
  398. ? []
  399. : childLinks.map(l => {
  400. if (processedNodes.has(l.target)) {
  401. // 入继(sub_relation_type===3)子女已在亲生父处渲染过,
  402. // 在养父下创建浅引用节点(不再递归,避免重复/死循环)
  403. if (l.sub_relation_type === 3) {
  404. const cn = nodes.find(n => n.id === l.target);
  405. if (cn) {
  406. return {
  407. id: cn.id,
  408. name: cn.name,
  409. simplified_name: cn.simplified_name,
  410. sex: cn.sex,
  411. adoptedIn: true,
  412. hasHierarchicalChildren: false,
  413. isCollapsed: false,
  414. children: []
  415. };
  416. }
  417. }
  418. return null;
  419. }
  420. return buildHierarchy(l.target, processedNodes);
  421. }).filter(c => c !== null);
  422. const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
  423. .map(l => {
  424. const spouseId = l.child_mid;
  425. if (!childIds.has(spouseId)) {
  426. const sNode = nodes.find(n => n.id === spouseId);
  427. if (sNode && !processedNodes.has(spouseId)) {
  428. processedNodes.add(spouseId);
  429. return { id: sNode.id, name: sNode.name, simplified_name: sNode.simplified_name, sex: sNode.sex, isSpouseNode: true, children: [] };
  430. }
  431. }
  432. return null;
  433. })
  434. .filter(s => s !== null);
  435. return {
  436. id: node.id,
  437. name: node.name,
  438. simplified_name: node.simplified_name,
  439. sex: node.sex,
  440. adoptedOut: node.adoptedOut,
  441. adoptedOutTarget: node.adoptedOutTarget,
  442. hasHierarchicalChildren,
  443. isCollapsed,
  444. children: spouses.concat(children)
  445. };
  446. }
  447. let treeData;
  448. if (roots.length > 1) {
  449. treeData = { name: "家谱根源", children: roots.map(root => buildHierarchy(root.id)).filter(r => r !== null) };
  450. } else if (roots.length === 1) {
  451. treeData = buildHierarchy(roots[0].id);
  452. } else {
  453. treeData = buildHierarchy(nodes[0].id);
  454. }
  455. const margin = {top: 80, right: 60, bottom: 80, left: 60};
  456. const containerWidth = document.getElementById('tree-container').offsetWidth;
  457. let rootNode = d3.hierarchy(treeData, d => (d && d.children) || []);
  458. // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
  459. const nodeWidth = 120; // 增加基础宽度以避免重叠
  460. const nodeHeight = 260; // 基础高度,调大以避免上下层重叠
  461. const treemap = d3.tree().nodeSize([nodeWidth, nodeHeight]).separation((a, b) => {
  462. return a.parent === b.parent ? 2.0 : 2.0; // 增加同级节点间距
  463. });
  464. let nodesHier = treemap(rootNode);
  465. // 配偶固定在同一水平线上:与本人并列显示
  466. const spouseSpreadX = 140; // 增加间距以确保不重叠
  467. nodesHier.descendants().forEach(parent => {
  468. const spouses = (parent.children || []).filter(c => c.data && c.data.isSpouseNode);
  469. if (spouses.length === 0) return;
  470. spouses.forEach((spouse, idx) => {
  471. // 计算配偶位置,从右侧开始排列
  472. spouse.x = parent.x + (idx + 1) * spouseSpreadX;
  473. spouse.y = parent.y; // 同一水平线
  474. });
  475. });
  476. // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
  477. let x0 = Infinity;
  478. let x1 = -Infinity;
  479. let y1 = -Infinity;
  480. nodesHier.descendants().forEach(d => {
  481. if (d.x < x0) x0 = d.x;
  482. if (d.x > x1) x1 = d.x;
  483. if (d.y > y1) y1 = d.y;
  484. });
  485. // 增加更宽的边距以确保左侧不被切断
  486. const minWidth = containerWidth;
  487. const calculatedWidth = x1 - x0 + margin.left + margin.right + 400; // 增加额外的宽度缓冲
  488. const svgWidth = Math.max(minWidth, calculatedWidth);
  489. const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
  490. // 修正偏移量计算:确保最小的 x0 节点完全在可视区域内(加上足够的左边距)
  491. // 这样即便是负的很大,也会被完整平移到正数区域
  492. const extraLeftMargin = 200; // 增加更多左侧空间
  493. let offsetX = margin.left - x0 + extraLeftMargin; // 强制将最左侧节点右移确保文字不被截断
  494. // 确保offsetX至少为margin.left,防止内容被左侧菜单遮挡
  495. offsetX = Math.max(offsetX, margin.left + 50);
  496. const svg = d3.select("#tree-container").append("svg")
  497. .attr("width", svgWidth)
  498. .attr("height", svgHeight)
  499. .call(zoomBehavior)
  500. .append("g")
  501. .attr("transform", `translate(${offsetX},${margin.top})`);
  502. // 存储SVG元素引用,用于缩放操作
  503. window.svgGroup = svg;
  504. // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
  505. const circleR = 20;
  506. // 辅助函数:绘制精致的徽章式关系标签
  507. function addBadge(g, x, y, text) {
  508. const group = g.append("g").attr("transform", `translate(${x},${y})`);
  509. group.append("rect")
  510. .attr("x", -20).attr("y", -10)
  511. .attr("width", 40).attr("height", 20)
  512. .attr("rx", 10).attr("ry", 10) // 胶囊形状
  513. .attr("fill", "#fff")
  514. .attr("stroke", "#CBD5E1").attr("stroke-width", 1.2);
  515. group.append("text")
  516. .attr("class", "link-label")
  517. .attr("x", 0).attr("y", 0).attr("dy", "0.32em")
  518. .attr("text-anchor", "middle")
  519. .text(text);
  520. }
  521. // 连线:第二层连线设计(U型配偶线 + 亲子水平线)
  522. nodesHier.descendants().forEach(node => {
  523. if (!node.children || node.children.length === 0) return;
  524. const realChildren = (node.children || []).filter(c => c.data && !c.data.isSpouseNode);
  525. const spouses = (node.children || []).filter(c => c.data && c.data.isSpouseNode);
  526. // 配偶连线拐点:位于本人与配偶节点之间,匹配“下层半级”显示
  527. // const hY = node.y + Math.round(spouseHalfLevelOffsetY * 0.55);
  528. const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
  529. // 夫妻关系:水平连线(主节点右侧连接到配偶左侧)
  530. if (spouses.length > 0) {
  531. const g = svg.append("g").attr("class", "link-group");
  532. spouses.forEach((spouse, idx) => {
  533. // 水平连线,稍微向下偏移一点以避开节点
  534. const linkY = node.y + 10;
  535. const pathD = `M${num(node.x + circleR)},${num(linkY)}
  536. L${num(spouse.x - circleR)},${num(linkY)}`;
  537. g.append("path").attr("class", "link link-spouse").attr("d", pathD);
  538. if (idx === 0) addBadge(g, (node.x + spouse.x) / 2, linkY, "配偶");
  539. });
  540. }
  541. if (realChildren.length === 0) return;
  542. const childrenY = realChildren[0].y;
  543. const minChildX = d3.min(realChildren, c => c.x);
  544. const maxChildX = d3.max(realChildren, c => c.x);
  545. // 如果有配偶,子代主线从本人和最右侧配偶的中间引出,否则从自己直接引出
  546. const startX = spouses.length > 0 ? (node.x + spouses[spouses.length - 1].x) / 2 : node.x;
  547. // 若节点有折叠按钮(底部),连线从按钮下方起始
  548. const startY = node.y + circleR + (node.data.hasHierarchicalChildren ? 28 : 0);
  549. // 将子女横线也相应地下移一些,避免和配偶长名字重叠
  550. const sibsY = childrenY - 60;
  551. const g = svg.append("g").attr("class", "link-group");
  552. const hLineLeft = Math.min(minChildX, startX);
  553. const hLineRight = Math.max(maxChildX, startX);
  554. // 主线:从配偶U型线中点连到子女水平线
  555. let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} M${num(hLineLeft)},${num(sibsY)} L${num(hLineRight)},${num(sibsY)}`;
  556. // 短竖线:连到每个子女顶部
  557. realChildren.forEach(child => {
  558. pathD += ` M${num(child.x)},${num(sibsY)} L${num(child.x)},${num(child.y) - circleR}`;
  559. });
  560. g.append("path").attr("class", "link link-parent-child").attr("d", pathD);
  561. // 去除家谱根源到第一层的关系展示;下层亲子关系标记使用徽章式标签放在短竖线上,一目了然
  562. const isRootToFirst = !node.parent || !(node.data && node.data.id);
  563. if (!isRootToFirst && node.data) {
  564. realChildren.forEach(child => {
  565. const pSex = node.data.sex;
  566. const cSex = child.data && child.data.sex;
  567. let label = "亲子";
  568. if (child.data && child.data.adoptedIn) {
  569. label = cSex === 1 ? "养子" : "养女";
  570. } else if (pSex === 1) {
  571. label = cSex === 1 ? "父子" : "父女";
  572. } else if (pSex === 2) {
  573. label = cSex === 1 ? "母子" : "母女";
  574. }
  575. const childLinkMidY = (sibsY + child.y - circleR) / 2;
  576. addBadge(g, child.x, childLinkMidY, label);
  577. });
  578. }
  579. });
  580. // 兄弟/姐妹:上方 U 型连线避免穿透节点
  581. const idToPos = {};
  582. nodesHier.descendants().forEach(d => { if (d.data.id) idToPos[d.data.id] = { x: d.x, y: d.y }; });
  583. otherLinks.forEach(rel => {
  584. const s = idToPos[rel.parent_mid], t = idToPos[rel.child_mid];
  585. if (s && t) {
  586. const x1 = Math.min(s.x, t.x), x2 = Math.max(s.x, t.x);
  587. const y = s.y - circleR - 25; // 兄弟线在节点上方
  588. const g = svg.append("g");
  589. const pathD = `M${x1},${s.y - circleR} L${x1},${y} L${x2},${y} L${x2},${t.y - circleR}`;
  590. g.append("path").attr("class", "link link-sibling").attr("d", pathD);
  591. addBadge(g, (x1 + x2) / 2, y, rel.relation_type === 11 ? "兄弟" : "姐妹");
  592. }
  593. });
  594. const node = svg.selectAll(".node")
  595. .data(nodesHier.descendants())
  596. .enter().append("g")
  597. .attr("class", d => {
  598. let cls = "node" + (d.children ? " node--internal" : " node--leaf");
  599. if (d.data.sex === 1) cls += " node-male";
  600. else if (d.data.sex === 2) cls += " node-female";
  601. if (d.data.adoptedOut) cls += " node-adopted-out";
  602. return cls;
  603. })
  604. .attr("transform", d => `translate(${d.x},${d.y})`)
  605. .on("contextmenu", function(event, d) {
  606. if (!d.data.id) return;
  607. event.preventDefault();
  608. selectedMid = d.data.id;
  609. // 动态更新折叠菜单项文字与可见性
  610. const collapseItem = document.getElementById('collapseMenuItem');
  611. if (d.data.hasHierarchicalChildren) {
  612. collapseItem.style.display = '';
  613. const icon = collapseItem.querySelector('i');
  614. if (collapsedNodes.has(d.data.id)) {
  615. icon.className = 'bi bi-arrows-expand';
  616. collapseItem.childNodes[1].textContent = '展开子节点';
  617. } else {
  618. icon.className = 'bi bi-arrows-collapse';
  619. collapseItem.childNodes[1].textContent = '收起子节点';
  620. }
  621. } else {
  622. collapseItem.style.display = 'none';
  623. }
  624. const containerRect = document.getElementById('tree-container').getBoundingClientRect();
  625. contextMenu.style.display = 'block';
  626. contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
  627. contextMenu.style.top = (event.clientY - containerRect.top) + 'px';
  628. })
  629. .call(d3.drag()
  630. .on("start", dragstarted)
  631. .on("drag", dragged)
  632. .on("end", dragended));
  633. // 图形:男性方形,女性圆形,更符合生物遗传图谱
  634. node.each(function(d) {
  635. const el = d3.select(this);
  636. // 出继节点 hover 提示
  637. if (d.data.adoptedOut) {
  638. const tip = d.data.adoptedOutTarget ? `出继给 ${d.data.adoptedOutTarget}` : '出继';
  639. el.append('title').text(tip);
  640. }
  641. if (d.data.sex === 1) {
  642. el.append("rect")
  643. .attr("x", -circleR).attr("y", -circleR)
  644. .attr("width", circleR * 2).attr("height", circleR * 2)
  645. .style("cursor", "grab");
  646. } else {
  647. el.append("circle")
  648. .attr("r", circleR)
  649. .style("cursor", "grab");
  650. }
  651. });
  652. // 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
  653. // 有折叠按钮的节点多留出按钮空间(按钮中心在 y=circleR,高度约 20px,再留 8px 间距)
  654. // circleR(20) + 按钮中心(12) + 按钮半径(11) + 间距(6) = 49
  655. const nameOffsetY = circleR + 49;
  656. const maxNameLen = 12; // 允许稍微长一点的文字
  657. function fullName(d) {
  658. if (!d.data) return '';
  659. return d.data.simplified_name ? `${d.data.name || ''} (${d.data.simplified_name})` : (d.data.name || '');
  660. }
  661. const nameGroup = node.append("g").attr("class", "node-name-wrap").attr("transform", "translate(0, " + nameOffsetY + ")");
  662. nameGroup.each(function(d) {
  663. const g = d3.select(this);
  664. const full = fullName(d);
  665. const disp = full.length <= maxNameLen ? full : full.slice(0, maxNameLen) + '…';
  666. // 姓名可能较长,我们这里做个简单的多行拆分显示或者让它有一个白色背景遮挡线
  667. // 这里为了简单不破坏原有结构,仅使用 text,但可以给一个白色的 stroke 做底或者调整 y 坐标
  668. const textNode = g.append("text")
  669. .attr("class", "node-name")
  670. .attr("x", 0).attr("y", 0)
  671. .attr("dy", "0.8em") // 使得文字基线往下靠,进一步远离图形
  672. .attr("text-anchor", "middle")
  673. .style("pointer-events", "all")
  674. .style("cursor", "default")
  675. .text(disp);
  676. if (full.length > maxNameLen) {
  677. textNode.append("title").text(full);
  678. }
  679. });
  680. // 标记折叠节点样式
  681. node.each(function(d) {
  682. if (d.data.isCollapsed) d3.select(this).classed("node-collapsed", true);
  683. });
  684. // ── 折叠/展开按钮 ──────────────────────────────────────────────────
  685. // 直接追加到 SVG 坐标层(非节点 g 内部),彻底脱离 drag 事件树,确保可见可点
  686. nodesHier.descendants().forEach(function(nd) {
  687. if (!nd.data.id || !nd.data.hasHierarchicalChildren) return;
  688. const collapsed = nd.data.isCollapsed;
  689. const bx = nd.x;
  690. const by = nd.y + circleR + 14; // 形状底边再往下 14px
  691. const tg = svg.append("g")
  692. .attr("class", "collapse-toggle")
  693. .attr("transform", `translate(${bx},${by})`)
  694. .style("cursor", "pointer");
  695. tg.on("mousedown", function(e) { e.stopPropagation(); });
  696. tg.on("click", function(e) {
  697. e.stopPropagation();
  698. const savedTransform = zoomTransform; // 保存当前视图位置
  699. if (collapsedNodes.has(nd.data.id)) {
  700. collapsedNodes.delete(nd.data.id);
  701. } else {
  702. collapsedNodes.add(nd.data.id);
  703. }
  704. renderTree(currentData);
  705. // 重建后立即还原 zoom 位置,不触发动画
  706. d3.select("#tree-container svg").call(zoomBehavior.transform, savedTransform);
  707. });
  708. tg.append("title").text(collapsed ? "展开子节点" : "收起子节点");
  709. // 白色底圆(防止线条透底)
  710. tg.append("circle").attr("r", 11).attr("fill", "white");
  711. // 彩色实心圆:展开=蓝,折叠=绿
  712. tg.append("circle")
  713. .attr("r", 10)
  714. .attr("fill", collapsed ? "#10B981" : "#3B82F6")
  715. .attr("stroke", "white").attr("stroke-width", 1.5);
  716. // 符号文字
  717. tg.append("text")
  718. .attr("text-anchor", "middle").attr("dy", "0.38em")
  719. .attr("fill", "white").attr("font-size", "16px").attr("font-weight", "bold")
  720. .attr("pointer-events", "none")
  721. .text(collapsed ? "+" : "−");
  722. });
  723. function dragstarted(event, d) {
  724. if (!d.data.id) return;
  725. d3.select(this).raise().classed("active", true);
  726. d._currentX = d.x; d._currentY = d.y;
  727. dragSource = d.data;
  728. }
  729. function dragged(event, d) {
  730. d._currentX += event.dx; d._currentY += event.dy;
  731. d3.select(this).attr("transform", `translate(${d._currentX},${d._currentY})`);
  732. }
  733. function dragended(event, d) {
  734. d3.select(this).classed("active", false);
  735. const mouseX = event.sourceEvent.clientX, mouseY = event.sourceEvent.clientY;
  736. let foundNode = null;
  737. svg.selectAll(".node").each(function(nodeData) {
  738. if (!nodeData.data || nodeData.data.id === d.data.id || !nodeData.data.id) return;
  739. const rect = this.getBoundingClientRect();
  740. if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) foundNode = nodeData.data;
  741. });
  742. if (foundNode && foundNode.id) {
  743. dragTarget = foundNode;
  744. document.getElementById('sourceMid').value = dragSource.id;
  745. document.getElementById('targetMid').value = dragTarget.id;
  746. document.getElementById('relationInfo').innerHTML = `确认将 <strong>${dragSource.name}</strong> 设定为 <strong>${dragTarget.name}</strong> 的关系人?`;
  747. relationModal.show();
  748. }
  749. setTimeout(() => { renderTree(currentData); }, 100);
  750. }
  751. } catch (err) {
  752. console.error('renderTree error:', err);
  753. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">关系图渲染出错,请刷新重试。<br>' + (err.message || '') + '</div>';
  754. }
  755. }
  756. function saveRelation() {
  757. const payload = {
  758. source_mid: document.getElementById('sourceMid').value,
  759. target_mid: document.getElementById('targetMid').value,
  760. relation_type: document.getElementById('relType').value,
  761. sub_relation_type: document.getElementById('subRelType').value
  762. };
  763. fetch('/manager/api/save_relation', {
  764. method: 'POST',
  765. headers: { 'Content-Type': 'application/json' },
  766. body: JSON.stringify(payload)
  767. }).then(res => res.json()).then(data => {
  768. if (data.success) { relationModal.hide(); loadTree(); }
  769. else { alert('保存失败: ' + data.message); }
  770. });
  771. }
  772. // 缩放函数
  773. function zoomed(event) {
  774. zoomTransform = event.transform;
  775. zoomScale = event.transform.k;
  776. if (window.svgGroup) {
  777. window.svgGroup.attr("transform", event.transform);
  778. }
  779. }
  780. function zoomIn() {
  781. if (zoomScale < 4) {
  782. const newScale = zoomScale * 1.2;
  783. const container = document.getElementById('tree-container');
  784. const centerX = container.clientWidth / 2;
  785. const centerY = container.clientHeight / 2;
  786. const svg = d3.select("#tree-container svg");
  787. svg.transition().duration(300).call(
  788. zoomBehavior.transform,
  789. d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
  790. );
  791. }
  792. }
  793. function zoomOut() {
  794. if (zoomScale > 0.1) {
  795. const newScale = zoomScale / 1.2;
  796. const container = document.getElementById('tree-container');
  797. const centerX = container.clientWidth / 2;
  798. const centerY = container.clientHeight / 2;
  799. const svg = d3.select("#tree-container svg");
  800. svg.transition().duration(300).call(
  801. zoomBehavior.transform,
  802. d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
  803. );
  804. }
  805. }
  806. function zoomReset() {
  807. const svg = d3.select("#tree-container svg");
  808. svg.transition().duration(300).call(
  809. zoomBehavior.transform,
  810. d3.zoomIdentity
  811. );
  812. zoomScale = 1;
  813. }
  814. // 搜索成员并定位
  815. function searchMember() {
  816. const searchTerm = document.getElementById('memberSearch').value.trim();
  817. if (!searchTerm) {
  818. alert('请输入成员名字');
  819. return;
  820. }
  821. if (!currentData || !currentData.members) {
  822. alert('数据未加载完成,请稍后再试');
  823. return;
  824. }
  825. // 搜索匹配的成员
  826. const matchedMembers = currentData.members.filter(member => {
  827. if (!member) return false;
  828. const name = (member.name || '').toLowerCase();
  829. const simplifiedName = (member.simplified_name || '').toLowerCase();
  830. const searchLower = searchTerm.toLowerCase();
  831. return name.includes(searchLower) || simplifiedName.includes(searchLower);
  832. });
  833. if (matchedMembers.length === 0) {
  834. alert('未找到匹配的成员');
  835. return;
  836. }
  837. // 如果找到多个匹配项,让用户选择
  838. let targetMember;
  839. if (matchedMembers.length === 1) {
  840. targetMember = matchedMembers[0];
  841. } else {
  842. const memberNames = matchedMembers.map(m => `${m.name} (${m.simplified_name || '无简化名'})`).join('\n');
  843. const selectedIndex = prompt(`找到多个匹配成员,请输入编号选择:\n${matchedMembers.map((m, i) => `${i + 1}. ${m.name} (${m.simplified_name || '无简化名'})`).join('\n')}`);
  844. const index = parseInt(selectedIndex) - 1;
  845. if (isNaN(index) || index < 0 || index >= matchedMembers.length) {
  846. return;
  847. }
  848. targetMember = matchedMembers[index];
  849. }
  850. // 定位到成员
  851. locateMember(targetMember.id);
  852. }
  853. // 定位到指定成员
  854. function locateMember(memberId) {
  855. const svg = d3.select("#tree-container svg");
  856. if (!svg.empty()) {
  857. // 查找对应的节点
  858. const node = d3.selectAll(".node").filter(function(d) {
  859. return d.data && d.data.id === memberId;
  860. });
  861. if (node.size() > 0) {
  862. // 获取节点位置
  863. const nodeData = node.datum();
  864. const container = document.getElementById('tree-container');
  865. const containerWidth = container.clientWidth;
  866. const containerHeight = container.clientHeight;
  867. // 计算缩放和平移,使节点位于中心
  868. const scale = 1.5; // 放大一点以突出显示
  869. const translateX = containerWidth / 2 - nodeData.x * scale;
  870. const translateY = containerHeight / 2 - nodeData.y * scale;
  871. // 应用变换
  872. svg.transition().duration(1000).call(
  873. zoomBehavior.transform,
  874. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  875. );
  876. // 高亮显示节点
  877. node.select("rect, circle").transition().duration(500)
  878. .attr("stroke", "#ff0000")
  879. .attr("stroke-width", 3)
  880. .transition().duration(1000).attr("stroke", function(d) {
  881. return d.data.sex === 1 ? "#3B82F6" : "#EC4899";
  882. }).attr("stroke-width", 2);
  883. } else {
  884. alert('未在树中找到该成员');
  885. }
  886. } else {
  887. alert('树图未加载完成,请稍后再试');
  888. }
  889. }
  890. // 支持回车键搜索
  891. document.getElementById('memberSearch').addEventListener('keypress', function(e) {
  892. if (e.key === 'Enter') {
  893. searchMember();
  894. }
  895. });
  896. </script>
  897. {% endblock %}