tree.html 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799
  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: #EF4444;
  81. stroke-width: 2px;
  82. stroke-dasharray: 6, 4;
  83. fill: rgba(239, 68, 68, 0.1);
  84. }
  85. .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
  86. /* 未知性别默认 */
  87. .node-leaf circle, .node-internal circle { stroke: #94A3B8; fill: #F8FAFC; }
  88. .node-male circle { stroke: none; fill: none; } /* 清除可能的干扰 */
  89. /* 样图一致:全部细实线 */
  90. .link-parent-child { stroke: #333; stroke-dasharray: none; }
  91. .link-spouse { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  92. .link-sibling { stroke: #333; stroke-dasharray: none; stroke-width: 1.2px; }
  93. /* 右键菜单样式 */
  94. .context-menu {
  95. position: absolute;
  96. display: none;
  97. background: white;
  98. border: 1px solid #ccc;
  99. box-shadow: 2px 2px 10px rgba(0,0,0,0.2);
  100. z-index: 1000;
  101. border-radius: 4px;
  102. padding: 5px 0;
  103. min-width: 120px;
  104. }
  105. .context-menu-item {
  106. padding: 8px 15px;
  107. cursor: pointer;
  108. font-size: 14px;
  109. color: #333;
  110. }
  111. .context-menu-item:hover {
  112. background-color: #f8f9fa;
  113. color: #0d6efd;
  114. }
  115. .context-menu-item i {
  116. margin-right: 8px;
  117. }
  118. </style>
  119. {% endblock %}
  120. {% block content %}
  121. <div class="d-flex justify-content-between align-items-center mb-3">
  122. <h2><i class="bi bi-diagram-3"></i> 家谱关系树状图</h2>
  123. <div class="d-flex gap-2">
  124. <div class="input-group" style="width: 300px;">
  125. <input type="text" id="memberSearch" class="form-control form-control-sm" placeholder="输入成员名字搜索">
  126. <button class="btn btn-sm btn-primary" onclick="searchMember()">
  127. <i class="bi bi-search"></i> 搜索
  128. </button>
  129. </div>
  130. <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-primary btn-sm">
  131. <i class="bi bi-printer"></i> 导出传统吊线图
  132. </a>
  133. </div>
  134. </div>
  135. <div class="alert alert-light border small py-2">
  136. <i class="bi bi-info-circle me-1"></i> 提示:图中按家谱世系树状图格式展示。支持拖拽建立关系,右键点击成员可查看、编辑或新增。
  137. </div>
  138. <div id="tree-container">
  139. <!-- 缩放控制 -->
  140. <div class="zoom-controls">
  141. <button class="zoom-btn" onclick="zoomIn()"><i class="bi bi-plus"></i></button>
  142. <button class="zoom-btn" onclick="zoomOut()"><i class="bi bi-dash"></i></button>
  143. <button class="zoom-btn" onclick="zoomReset()"><i class="bi bi-arrow-counterclockwise"></i></button>
  144. </div>
  145. <!-- 右键菜单 -->
  146. <div id="contextMenu" class="context-menu">
  147. <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
  148. <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
  149. <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
  150. </div>
  151. </div>
  152. <!-- 关系选择弹窗 -->
  153. <div class="modal fade" id="relationModal" tabindex="-1">
  154. <div class="modal-dialog modal-sm">
  155. <div class="modal-content">
  156. <div class="modal-header">
  157. <h5 class="modal-title">建立关系</h5>
  158. <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
  159. </div>
  160. <div class="modal-body">
  161. <p id="relationInfo" class="small mb-3"></p>
  162. <input type="hidden" id="sourceMid">
  163. <input type="hidden" id="targetMid">
  164. <div class="mb-3">
  165. <label class="form-label small">关系类型</label>
  166. <select id="relType" class="form-select form-select-sm">
  167. <option value="1">是其 儿子/女儿</option>
  168. <option value="10">是其 妻子/丈夫</option>
  169. <option value="11">是其 兄弟</option>
  170. <option value="12">是其 姐妹</option>
  171. </select>
  172. </div>
  173. <div class="mb-3">
  174. <label class="form-label small">子类型</label>
  175. <select id="subRelType" class="form-select form-select-sm">
  176. <option value="0">亲生/正妻</option>
  177. <option value="1">养子/女</option>
  178. <option value="2">过继</option>
  179. </select>
  180. </div>
  181. </div>
  182. <div class="modal-footer">
  183. <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">取消</button>
  184. <button type="button" class="btn btn-sm btn-primary" onclick="saveRelation()">保存关系</button>
  185. </div>
  186. </div>
  187. </div>
  188. </div>
  189. {% endblock %}
  190. {% block extra_js %}
  191. <!-- 优先使用本地 D3.js -->
  192. <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
  193. <!-- fallback to CDN if local fails -->
  194. <script>
  195. if (typeof d3 === 'undefined') {
  196. var script = document.createElement('script');
  197. script.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
  198. script.onload = function() {
  199. if (typeof d3 !== 'undefined') {
  200. loadTree();
  201. } else {
  202. var container = document.getElementById('tree-container');
  203. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  204. }
  205. };
  206. script.onerror = function() {
  207. var container = document.getElementById('tree-container');
  208. if (container) container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">D3.js 未加载,请检查网络或稍后重试。</div>';
  209. };
  210. document.head.appendChild(script);
  211. }
  212. </script>
  213. <script>
  214. (function() {
  215. if (typeof d3 === 'undefined') {
  216. // CDN will handle loading and initialization
  217. }
  218. })();
  219. let currentData = null;
  220. let dragSource = null;
  221. let dragTarget = null;
  222. let selectedMid = null; // 当前选中的成员 ID
  223. let zoomScale = 1;
  224. let zoomTransform = d3.zoomIdentity;
  225. let zoomBehavior = d3.zoom().scaleExtent([0.1, 4]).on("zoom", zoomed);
  226. const relationModal = new bootstrap.Modal(document.getElementById('relationModal'));
  227. const contextMenu = document.getElementById('contextMenu');
  228. // 隐藏右键菜单
  229. window.addEventListener('click', () => {
  230. contextMenu.style.display = 'none';
  231. });
  232. // 处理菜单点击
  233. function menuAction(type) {
  234. if (!selectedMid && type !== 'add') return;
  235. switch(type) {
  236. case 'detail':
  237. window.location.href = `/manager/member_detail/${selectedMid}`;
  238. break;
  239. case 'edit':
  240. window.location.href = `/manager/edit_member/${selectedMid}`;
  241. break;
  242. case 'add':
  243. window.location.href = '/manager/add_member';
  244. break;
  245. }
  246. }
  247. // 获取数据并渲染
  248. function loadTree() {
  249. if (typeof d3 === 'undefined') return;
  250. fetch('/manager/api/tree_data')
  251. .then(response => response.json())
  252. .then(data => {
  253. currentData = data;
  254. renderTree(data);
  255. });
  256. }
  257. if (typeof d3 !== 'undefined') loadTree();
  258. function renderTree(data) {
  259. const container = document.getElementById('tree-container');
  260. container.innerHTML = '';
  261. container.appendChild(contextMenu);
  262. try {
  263. const { members, relations } = data;
  264. if (!members || members.length === 0) {
  265. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-muted">暂无成员数据,无法生成关系图。</div>';
  266. return;
  267. }
  268. // 构建节点,保留所有关系信息
  269. const nodes = members.map(m => ({ id: m.id, name: m.name, simplified_name: m.simplified_name, sex: m.sex, adoptedOut: false, adoptedOutTarget: null }));
  270. // 获取所有父子关系(包括出继/入继)
  271. const allHierarchicalRelations = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
  272. // 对于出继的子女,记录他们入继到的目标
  273. const adoptedOutMap = new Map();
  274. allHierarchicalRelations.forEach(r => {
  275. if (r.sub_relation_type === 2) { // 出继
  276. const targetRelation = allHierarchicalRelations.find(r2 => r2.child_mid === r.child_mid && r2.sub_relation_type === 3);
  277. if (targetRelation) {
  278. const targetMember = members.find(m => m.id === targetRelation.parent_mid);
  279. if (targetMember) {
  280. adoptedOutMap.set(r.child_mid, targetMember.name);
  281. }
  282. }
  283. }
  284. });
  285. // 更新节点的出继标记
  286. nodes.forEach(node => {
  287. if (adoptedOutMap.has(node.id)) {
  288. node.adoptedOut = true;
  289. node.adoptedOutTarget = adoptedOutMap.get(node.id);
  290. }
  291. });
  292. // 保留所有父子关系(包括出继),用于构建树
  293. const hierarchicalLinks = allHierarchicalRelations.map(r => ({
  294. source: r.parent_mid,
  295. target: r.child_mid,
  296. sub_relation_type: r.sub_relation_type
  297. }));
  298. const spouseLinks = relations.filter(r => r.relation_type === 10);
  299. const otherLinks = relations.filter(r => r.relation_type >= 11);
  300. const childIds = new Set(hierarchicalLinks.map(l => l.target));
  301. const allSpouseIds = new Set(spouseLinks.map(l => l.child_mid));
  302. const roots = nodes.filter(n => !childIds.has(n.id) && !allSpouseIds.has(n.id));
  303. function buildHierarchy(nodeId, processedNodes = new Set()) {
  304. const node = nodes.find(n => n.id === nodeId);
  305. if (!node || processedNodes.has(nodeId)) return null;
  306. processedNodes.add(nodeId);
  307. const children = hierarchicalLinks.filter(l => l.source === nodeId)
  308. .map(l => buildHierarchy(l.target, processedNodes))
  309. .filter(c => c !== null);
  310. const spouses = spouseLinks.filter(l => l.parent_mid === nodeId)
  311. .map(l => {
  312. const spouseId = l.child_mid;
  313. if (!childIds.has(spouseId)) {
  314. const sNode = nodes.find(n => n.id === spouseId);
  315. if (sNode && !processedNodes.has(spouseId)) {
  316. processedNodes.add(spouseId);
  317. return { id: sNode.id, name: sNode.name, simplified_name: sNode.simplified_name, sex: sNode.sex, isSpouseNode: true, children: [] };
  318. }
  319. }
  320. return null;
  321. })
  322. .filter(s => s !== null);
  323. return { id: node.id, name: node.name, simplified_name: node.simplified_name, sex: node.sex, children: spouses.concat(children) };
  324. }
  325. let treeData;
  326. if (roots.length > 1) {
  327. treeData = { name: "家谱根源", children: roots.map(root => buildHierarchy(root.id)).filter(r => r !== null) };
  328. } else if (roots.length === 1) {
  329. treeData = buildHierarchy(roots[0].id);
  330. } else {
  331. treeData = buildHierarchy(nodes[0].id);
  332. }
  333. const margin = {top: 80, right: 60, bottom: 80, left: 60};
  334. const containerWidth = document.getElementById('tree-container').offsetWidth;
  335. let rootNode = d3.hierarchy(treeData, d => (d && d.children) || []);
  336. // 动态调整间距,保证节点绝对不重叠,长辈/同辈/配偶使用固定基础间距
  337. const nodeWidth = 120; // 增加基础宽度以避免重叠
  338. const nodeHeight = 260; // 基础高度,调大以避免上下层重叠
  339. const treemap = d3.tree().nodeSize([nodeWidth, nodeHeight]).separation((a, b) => {
  340. return a.parent === b.parent ? 2.0 : 2.0; // 增加同级节点间距
  341. });
  342. let nodesHier = treemap(rootNode);
  343. // 配偶固定在同一水平线上:与本人并列显示
  344. const spouseSpreadX = 140; // 增加间距以确保不重叠
  345. nodesHier.descendants().forEach(parent => {
  346. const spouses = (parent.children || []).filter(c => c.data && c.data.isSpouseNode);
  347. if (spouses.length === 0) return;
  348. spouses.forEach((spouse, idx) => {
  349. // 计算配偶位置,从右侧开始排列
  350. spouse.x = parent.x + (idx + 1) * spouseSpreadX;
  351. spouse.y = parent.y; // 同一水平线
  352. });
  353. });
  354. // 计算边界以动态设置 SVG 宽高,实现自动滚动不挤压
  355. let x0 = Infinity;
  356. let x1 = -Infinity;
  357. let y1 = -Infinity;
  358. nodesHier.descendants().forEach(d => {
  359. if (d.x < x0) x0 = d.x;
  360. if (d.x > x1) x1 = d.x;
  361. if (d.y > y1) y1 = d.y;
  362. });
  363. // 增加更宽的边距以确保左侧不被切断
  364. const minWidth = containerWidth;
  365. const calculatedWidth = x1 - x0 + margin.left + margin.right + 400; // 增加额外的宽度缓冲
  366. const svgWidth = Math.max(minWidth, calculatedWidth);
  367. const svgHeight = Math.max(600, y1 + margin.top + margin.bottom);
  368. // 修正偏移量计算:确保最小的 x0 节点完全在可视区域内(加上足够的左边距)
  369. // 这样即便是负的很大,也会被完整平移到正数区域
  370. const extraLeftMargin = 200; // 增加更多左侧空间
  371. let offsetX = margin.left - x0 + extraLeftMargin; // 强制将最左侧节点右移确保文字不被截断
  372. // 确保offsetX至少为margin.left,防止内容被左侧菜单遮挡
  373. offsetX = Math.max(offsetX, margin.left + 50);
  374. const svg = d3.select("#tree-container").append("svg")
  375. .attr("width", svgWidth)
  376. .attr("height", svgHeight)
  377. .call(zoomBehavior)
  378. .append("g")
  379. .attr("transform", `translate(${offsetX},${margin.top})`);
  380. // 存储SVG元素引用,用于缩放操作
  381. window.svgGroup = svg;
  382. // 节点圆圈半径(连线与节点共用),调大让图谱更清晰大气
  383. const circleR = 20;
  384. // 辅助函数:绘制精致的徽章式关系标签
  385. function addBadge(g, x, y, text) {
  386. const group = g.append("g").attr("transform", `translate(${x},${y})`);
  387. group.append("rect")
  388. .attr("x", -20).attr("y", -10)
  389. .attr("width", 40).attr("height", 20)
  390. .attr("rx", 10).attr("ry", 10) // 胶囊形状
  391. .attr("fill", "#fff")
  392. .attr("stroke", "#CBD5E1").attr("stroke-width", 1.2);
  393. group.append("text")
  394. .attr("class", "link-label")
  395. .attr("x", 0).attr("y", 0).attr("dy", "0.32em")
  396. .attr("text-anchor", "middle")
  397. .text(text);
  398. }
  399. // 连线:第二层连线设计(U型配偶线 + 亲子水平线)
  400. nodesHier.descendants().forEach(node => {
  401. if (!node.children || node.children.length === 0) return;
  402. const realChildren = (node.children || []).filter(c => c.data && !c.data.isSpouseNode);
  403. const spouses = (node.children || []).filter(c => c.data && c.data.isSpouseNode);
  404. // 配偶连线拐点:位于本人与配偶节点之间,匹配“下层半级”显示
  405. // const hY = node.y + Math.round(spouseHalfLevelOffsetY * 0.55);
  406. const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
  407. // 夫妻关系:水平连线(主节点右侧连接到配偶左侧)
  408. if (spouses.length > 0) {
  409. const g = svg.append("g").attr("class", "link-group");
  410. spouses.forEach((spouse, idx) => {
  411. // 水平连线,稍微向下偏移一点以避开节点
  412. const linkY = node.y + 10;
  413. const pathD = `M${num(node.x + circleR)},${num(linkY)}
  414. L${num(spouse.x - circleR)},${num(linkY)}`;
  415. g.append("path").attr("class", "link link-spouse").attr("d", pathD);
  416. if (idx === 0) addBadge(g, (node.x + spouse.x) / 2, linkY, "配偶");
  417. });
  418. }
  419. if (realChildren.length === 0) return;
  420. const childrenY = realChildren[0].y;
  421. const minChildX = d3.min(realChildren, c => c.x);
  422. const maxChildX = d3.max(realChildren, c => c.x);
  423. // 如果有配偶,子代主线从本人和最右侧配偶的中间引出,否则从自己直接引出
  424. const startX = spouses.length > 0 ? (node.x + spouses[spouses.length - 1].x) / 2 : node.x;
  425. const startY = node.y + circleR;
  426. // 将子女横线也相应地下移一些,避免和配偶长名字重叠
  427. const sibsY = childrenY - 60;
  428. const g = svg.append("g").attr("class", "link-group");
  429. const hLineLeft = Math.min(minChildX, startX);
  430. const hLineRight = Math.max(maxChildX, startX);
  431. // 主线:从配偶U型线中点连到子女水平线
  432. let pathD = `M${num(startX)},${num(startY)} L${num(startX)},${num(sibsY)} M${num(hLineLeft)},${num(sibsY)} L${num(hLineRight)},${num(sibsY)}`;
  433. // 短竖线:连到每个子女顶部
  434. realChildren.forEach(child => {
  435. pathD += ` M${num(child.x)},${num(sibsY)} L${num(child.x)},${num(child.y) - circleR}`;
  436. });
  437. g.append("path").attr("class", "link link-parent-child").attr("d", pathD);
  438. // 去除家谱根源到第一层的关系展示;下层亲子关系标记使用徽章式标签放在短竖线上,一目了然
  439. const isRootToFirst = !node.parent || !(node.data && node.data.id);
  440. if (!isRootToFirst && node.data) {
  441. realChildren.forEach(child => {
  442. const pSex = node.data.sex;
  443. const cSex = child.data && child.data.sex;
  444. let label = "亲子";
  445. if (pSex === 1) label = cSex === 1 ? "父子" : "父女";
  446. else if (pSex === 2) label = cSex === 1 ? "母子" : "母女";
  447. const childLinkMidY = (sibsY + child.y - circleR) / 2;
  448. addBadge(g, child.x, childLinkMidY, label);
  449. });
  450. }
  451. });
  452. // 兄弟/姐妹:上方 U 型连线避免穿透节点
  453. const idToPos = {};
  454. nodesHier.descendants().forEach(d => { if (d.data.id) idToPos[d.data.id] = { x: d.x, y: d.y }; });
  455. otherLinks.forEach(rel => {
  456. const s = idToPos[rel.parent_mid], t = idToPos[rel.child_mid];
  457. if (s && t) {
  458. const x1 = Math.min(s.x, t.x), x2 = Math.max(s.x, t.x);
  459. const y = s.y - circleR - 25; // 兄弟线在节点上方
  460. const g = svg.append("g");
  461. const pathD = `M${x1},${s.y - circleR} L${x1},${y} L${x2},${y} L${x2},${t.y - circleR}`;
  462. g.append("path").attr("class", "link link-sibling").attr("d", pathD);
  463. addBadge(g, (x1 + x2) / 2, y, rel.relation_type === 11 ? "兄弟" : "姐妹");
  464. }
  465. });
  466. const node = svg.selectAll(".node")
  467. .data(nodesHier.descendants())
  468. .enter().append("g")
  469. .attr("class", d => {
  470. let cls = "node" + (d.children ? " node--internal" : " node--leaf");
  471. if (d.data.sex === 1) cls += " node-male";
  472. else if (d.data.sex === 2) cls += " node-female";
  473. if (d.data.adoptedOut) cls += " node-adopted-out";
  474. return cls;
  475. })
  476. .attr("transform", d => `translate(${d.x},${d.y})`)
  477. .on("contextmenu", function(event, d) {
  478. if (!d.data.id) return;
  479. event.preventDefault();
  480. selectedMid = d.data.id;
  481. const containerRect = document.getElementById('tree-container').getBoundingClientRect();
  482. contextMenu.style.display = 'block';
  483. contextMenu.style.left = (event.clientX - containerRect.left) + 'px';
  484. contextMenu.style.top = (event.clientY - containerRect.top) + 'px';
  485. })
  486. .call(d3.drag()
  487. .on("start", dragstarted)
  488. .on("drag", dragged)
  489. .on("end", dragended));
  490. // 图形:男性方形,女性圆形,更符合生物遗传图谱
  491. node.each(function(d) {
  492. const el = d3.select(this);
  493. if (d.data.sex === 1) {
  494. el.append("rect")
  495. .attr("x", -circleR).attr("y", -circleR)
  496. .attr("width", circleR * 2).attr("height", circleR * 2)
  497. .style("cursor", "grab");
  498. } else {
  499. el.append("circle")
  500. .attr("r", circleR)
  501. .style("cursor", "grab");
  502. }
  503. });
  504. // 人名:往下移动更多,避免和长方形/圆形图形或者连线重叠
  505. const nameOffsetY = circleR + 25; // 增加间距
  506. const maxNameLen = 12; // 允许稍微长一点的文字
  507. function fullName(d) {
  508. if (!d.data) return '';
  509. return d.data.simplified_name ? `${d.data.name || ''} (${d.data.simplified_name})` : (d.data.name || '');
  510. }
  511. const nameGroup = node.append("g").attr("class", "node-name-wrap").attr("transform", "translate(0, " + nameOffsetY + ")");
  512. nameGroup.each(function(d) {
  513. const g = d3.select(this);
  514. const full = fullName(d);
  515. const disp = full.length <= maxNameLen ? full : full.slice(0, maxNameLen) + '…';
  516. // 姓名可能较长,我们这里做个简单的多行拆分显示或者让它有一个白色背景遮挡线
  517. // 这里为了简单不破坏原有结构,仅使用 text,但可以给一个白色的 stroke 做底或者调整 y 坐标
  518. const textNode = g.append("text")
  519. .attr("class", "node-name")
  520. .attr("x", 0).attr("y", 0)
  521. .attr("dy", "0.8em") // 使得文字基线往下靠,进一步远离图形
  522. .attr("text-anchor", "middle")
  523. .style("pointer-events", "all")
  524. .style("cursor", "default")
  525. .text(disp);
  526. if (full.length > maxNameLen) {
  527. textNode.append("title").text(full);
  528. }
  529. });
  530. function dragstarted(event, d) {
  531. if (!d.data.id) return;
  532. d3.select(this).raise().classed("active", true);
  533. d._currentX = d.x; d._currentY = d.y;
  534. dragSource = d.data;
  535. }
  536. function dragged(event, d) {
  537. d._currentX += event.dx; d._currentY += event.dy;
  538. d3.select(this).attr("transform", `translate(${d._currentX},${d._currentY})`);
  539. }
  540. function dragended(event, d) {
  541. d3.select(this).classed("active", false);
  542. const mouseX = event.sourceEvent.clientX, mouseY = event.sourceEvent.clientY;
  543. let foundNode = null;
  544. svg.selectAll(".node").each(function(nodeData) {
  545. if (!nodeData.data || nodeData.data.id === d.data.id || !nodeData.data.id) return;
  546. const rect = this.getBoundingClientRect();
  547. if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) foundNode = nodeData.data;
  548. });
  549. if (foundNode && foundNode.id) {
  550. dragTarget = foundNode;
  551. document.getElementById('sourceMid').value = dragSource.id;
  552. document.getElementById('targetMid').value = dragTarget.id;
  553. document.getElementById('relationInfo').innerHTML = `确认将 <strong>${dragSource.name}</strong> 设定为 <strong>${dragTarget.name}</strong> 的关系人?`;
  554. relationModal.show();
  555. }
  556. setTimeout(() => { renderTree(currentData); }, 100);
  557. }
  558. } catch (err) {
  559. console.error('renderTree error:', err);
  560. container.innerHTML = '<div class="h-100 d-flex align-items-center justify-content-center text-danger small">关系图渲染出错,请刷新重试。<br>' + (err.message || '') + '</div>';
  561. }
  562. }
  563. function saveRelation() {
  564. const payload = {
  565. source_mid: document.getElementById('sourceMid').value,
  566. target_mid: document.getElementById('targetMid').value,
  567. relation_type: document.getElementById('relType').value,
  568. sub_relation_type: document.getElementById('subRelType').value
  569. };
  570. fetch('/manager/api/save_relation', {
  571. method: 'POST',
  572. headers: { 'Content-Type': 'application/json' },
  573. body: JSON.stringify(payload)
  574. }).then(res => res.json()).then(data => {
  575. if (data.success) { relationModal.hide(); loadTree(); }
  576. else { alert('保存失败: ' + data.message); }
  577. });
  578. }
  579. // 缩放函数
  580. function zoomed(event) {
  581. zoomTransform = event.transform;
  582. zoomScale = event.transform.k;
  583. if (window.svgGroup) {
  584. window.svgGroup.attr("transform", event.transform);
  585. }
  586. }
  587. function zoomIn() {
  588. if (zoomScale < 4) {
  589. const newScale = zoomScale * 1.2;
  590. const container = document.getElementById('tree-container');
  591. const centerX = container.clientWidth / 2;
  592. const centerY = container.clientHeight / 2;
  593. const svg = d3.select("#tree-container svg");
  594. svg.transition().duration(300).call(
  595. zoomBehavior.transform,
  596. d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
  597. );
  598. }
  599. }
  600. function zoomOut() {
  601. if (zoomScale > 0.1) {
  602. const newScale = zoomScale / 1.2;
  603. const container = document.getElementById('tree-container');
  604. const centerX = container.clientWidth / 2;
  605. const centerY = container.clientHeight / 2;
  606. const svg = d3.select("#tree-container svg");
  607. svg.transition().duration(300).call(
  608. zoomBehavior.transform,
  609. d3.zoomIdentity.translate(centerX, centerY).scale(newScale).translate(-centerX, -centerY)
  610. );
  611. }
  612. }
  613. function zoomReset() {
  614. const svg = d3.select("#tree-container svg");
  615. svg.transition().duration(300).call(
  616. zoomBehavior.transform,
  617. d3.zoomIdentity
  618. );
  619. zoomScale = 1;
  620. }
  621. // 搜索成员并定位
  622. function searchMember() {
  623. const searchTerm = document.getElementById('memberSearch').value.trim();
  624. if (!searchTerm) {
  625. alert('请输入成员名字');
  626. return;
  627. }
  628. if (!currentData || !currentData.members) {
  629. alert('数据未加载完成,请稍后再试');
  630. return;
  631. }
  632. // 搜索匹配的成员
  633. const matchedMembers = currentData.members.filter(member => {
  634. if (!member) return false;
  635. const name = (member.name || '').toLowerCase();
  636. const simplifiedName = (member.simplified_name || '').toLowerCase();
  637. const searchLower = searchTerm.toLowerCase();
  638. return name.includes(searchLower) || simplifiedName.includes(searchLower);
  639. });
  640. if (matchedMembers.length === 0) {
  641. alert('未找到匹配的成员');
  642. return;
  643. }
  644. // 如果找到多个匹配项,让用户选择
  645. let targetMember;
  646. if (matchedMembers.length === 1) {
  647. targetMember = matchedMembers[0];
  648. } else {
  649. const memberNames = matchedMembers.map(m => `${m.name} (${m.simplified_name || '无简化名'})`).join('\n');
  650. const selectedIndex = prompt(`找到多个匹配成员,请输入编号选择:\n${matchedMembers.map((m, i) => `${i + 1}. ${m.name} (${m.simplified_name || '无简化名'})`).join('\n')}`);
  651. const index = parseInt(selectedIndex) - 1;
  652. if (isNaN(index) || index < 0 || index >= matchedMembers.length) {
  653. return;
  654. }
  655. targetMember = matchedMembers[index];
  656. }
  657. // 定位到成员
  658. locateMember(targetMember.id);
  659. }
  660. // 定位到指定成员
  661. function locateMember(memberId) {
  662. const svg = d3.select("#tree-container svg");
  663. if (!svg.empty()) {
  664. // 查找对应的节点
  665. const node = d3.selectAll(".node").filter(function(d) {
  666. return d.data && d.data.id === memberId;
  667. });
  668. if (node.size() > 0) {
  669. // 获取节点位置
  670. const nodeData = node.datum();
  671. const container = document.getElementById('tree-container');
  672. const containerWidth = container.clientWidth;
  673. const containerHeight = container.clientHeight;
  674. // 计算缩放和平移,使节点位于中心
  675. const scale = 1.5; // 放大一点以突出显示
  676. const translateX = containerWidth / 2 - nodeData.x * scale;
  677. const translateY = containerHeight / 2 - nodeData.y * scale;
  678. // 应用变换
  679. svg.transition().duration(1000).call(
  680. zoomBehavior.transform,
  681. d3.zoomIdentity.translate(translateX, translateY).scale(scale)
  682. );
  683. // 高亮显示节点
  684. node.select("rect, circle").transition().duration(500)
  685. .attr("stroke", "#ff0000")
  686. .attr("stroke-width", 3)
  687. .transition().duration(1000).attr("stroke", function(d) {
  688. return d.data.sex === 1 ? "#3B82F6" : "#EC4899";
  689. }).attr("stroke-width", 2);
  690. } else {
  691. alert('未在树中找到该成员');
  692. }
  693. } else {
  694. alert('树图未加载完成,请稍后再试');
  695. }
  696. }
  697. // 支持回车键搜索
  698. document.getElementById('memberSearch').addEventListener('keypress', function(e) {
  699. if (e.key === 'Enter') {
  700. searchMember();
  701. }
  702. });
  703. </script>
  704. {% endblock %}