tree_gen.html 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022
  1. {% extends "layout.html" %}
  2. {% block title %}世代分层家谱树 - 家谱管理系统{% endblock %}
  3. {% block extra_css %}
  4. <style>
  5. /* ── 容器 ── */
  6. #tree-gen-container {
  7. width: 100%;
  8. height: calc(100vh - 160px);
  9. min-height: 500px;
  10. background: #fafbfd;
  11. border: 1px solid #e9ecef;
  12. border-radius: 8px;
  13. position: relative;
  14. overflow: hidden;
  15. margin-top: 6px;
  16. box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
  17. }
  18. /* ── 左侧世代标签栏 ── */
  19. #gen-sidebar {
  20. position: absolute;
  21. left: 0; top: 0;
  22. width: 148px;
  23. height: 100%;
  24. z-index: 12;
  25. background: #f1f5f9;
  26. border-right: 1.5px solid #cbd5e1;
  27. overflow: hidden;
  28. /* pointer-events 由子元素各自控制 */
  29. }
  30. #gen-sidebar-title {
  31. position: absolute;
  32. top: 0; left: 0; right: 0;
  33. height: 38px;
  34. background: #e2e8f0;
  35. border-bottom: 1px solid #cbd5e1;
  36. display: flex;
  37. align-items: center;
  38. justify-content: center;
  39. font-size: 12px;
  40. font-weight: 600;
  41. color: #475569;
  42. font-family: 'Microsoft YaHei', sans-serif;
  43. z-index: 1;
  44. pointer-events: none;
  45. }
  46. /* 世代标签行(不可交互,仅展示) */
  47. .gen-label-row {
  48. position: absolute;
  49. left: 0; right: 0;
  50. text-align: center;
  51. transform: translateY(-50%);
  52. padding: 4px 6px;
  53. line-height: 1.5;
  54. pointer-events: none;
  55. user-select: none;
  56. }
  57. .gen-label-num {
  58. display: block;
  59. font-size: 13px;
  60. font-weight: bold;
  61. color: #334155;
  62. font-family: 'Microsoft YaHei', sans-serif;
  63. letter-spacing: 0.5px;
  64. }
  65. .gen-label-lineage {
  66. display: block;
  67. font-size: 10px;
  68. color: #94a3b8;
  69. font-family: 'Microsoft YaHei', sans-serif;
  70. margin-top: 1px;
  71. }
  72. /* ── 侧边栏折叠手柄 ── */
  73. .gen-collapse-handle {
  74. position: absolute;
  75. left: 0; right: 0;
  76. height: 20px;
  77. transform: translateY(-50%);
  78. display: flex;
  79. align-items: center;
  80. justify-content: center;
  81. pointer-events: auto;
  82. cursor: pointer;
  83. opacity: 0;
  84. transition: opacity 0.2s;
  85. z-index: 3;
  86. }
  87. #gen-sidebar:hover .gen-collapse-handle { opacity: 1; }
  88. .gen-collapse-handle:hover { opacity: 1 !important; }
  89. .gen-collapse-handle .h-line {
  90. position: absolute;
  91. left: 8px; right: 8px;
  92. height: 1.5px;
  93. background: #fbbf24;
  94. border-radius: 1px;
  95. }
  96. .gen-collapse-handle .h-btn {
  97. position: absolute;
  98. right: 6px;
  99. width: 16px; height: 16px;
  100. background: #fbbf24;
  101. border-radius: 50%;
  102. display: flex;
  103. align-items: center;
  104. justify-content: center;
  105. font-size: 9px;
  106. color: white;
  107. font-weight: 900;
  108. line-height: 1;
  109. box-shadow: 0 1px 3px rgba(0,0,0,0.2);
  110. }
  111. .gen-collapse-handle:hover .h-line { background: #f59e0b; height: 2px; }
  112. .gen-collapse-handle:hover .h-btn { background: #f59e0b; transform: scale(1.15); }
  113. /* 第一次点击后高亮 */
  114. .gen-collapse-handle.first-selected .h-line { background: #ef4444; height: 2px; }
  115. .gen-collapse-handle.first-selected .h-btn { background: #ef4444; }
  116. /* ── 已折叠世代行 ── */
  117. .gen-collapsed-row {
  118. position: absolute;
  119. left: 4px; right: 4px;
  120. transform: translateY(-50%);
  121. background: #fffbeb;
  122. border: 1.5px dashed #fbbf24;
  123. border-radius: 5px;
  124. padding: 4px 4px;
  125. text-align: center;
  126. pointer-events: auto;
  127. cursor: pointer;
  128. font-family: 'Microsoft YaHei', sans-serif;
  129. }
  130. .gen-collapsed-row:hover { background: #fef3c7; border-color: #f59e0b; }
  131. .gen-collapsed-row .cr-title {
  132. display: block;
  133. font-size: 11px;
  134. font-weight: bold;
  135. color: #92400e;
  136. line-height: 1.4;
  137. }
  138. .gen-collapsed-row .cr-hint {
  139. display: block;
  140. font-size: 9px;
  141. color: #b45309;
  142. margin-top: 1px;
  143. }
  144. /* ── SVG 节点 ── */
  145. .gen-node rect, .gen-node circle {
  146. stroke-width: 2px;
  147. filter: drop-shadow(0 2px 3px rgba(0,0,0,0.12));
  148. }
  149. .node-male rect { stroke: #3B82F6; fill: #EFF6FF; }
  150. .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
  151. .node-unknown circle, .node-unknown rect { stroke: #94A3B8; fill: #F8FAFC; }
  152. .node-name-text {
  153. font-family: 'Microsoft YaHei', sans-serif;
  154. font-size: 12px;
  155. fill: #334155;
  156. stroke: white;
  157. stroke-width: 3.5px;
  158. paint-order: stroke;
  159. stroke-linejoin: round;
  160. pointer-events: none;
  161. }
  162. /* 高亮 */
  163. .node-highlight rect { stroke: #ef4444 !important; stroke-width: 3px !important; }
  164. .node-highlight circle { stroke: #ef4444 !important; stroke-width: 3px !important; }
  165. /* 入继节点:虚线边框 */
  166. .node-adopted-in rect { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
  167. .node-adopted-in circle { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
  168. .node-adopt-label {
  169. font-family: 'Microsoft YaHei', sans-serif;
  170. font-size: 9px;
  171. fill: #f59e0b;
  172. stroke: white;
  173. stroke-width: 2px;
  174. paint-order: stroke;
  175. pointer-events: none;
  176. }
  177. /* ── 连线 ── */
  178. .link-parent { fill: none; stroke: #94a3b8; stroke-width: 1.6px; stroke-linejoin: round; }
  179. .link-spouse { fill: none; stroke: #f59e0b; stroke-width: 1.4px; stroke-dasharray: 5,3; }
  180. /* 折叠穿透虚线 */
  181. .link-collapse { fill: none; stroke: #f59e0b; stroke-width: 2px; stroke-dasharray: 8,4; }
  182. /* ── 折叠区间背景 ── */
  183. .collapse-stripe { fill: #fef3c7; opacity: 0.55; }
  184. /* ── 缩放控件 ── */
  185. .zoom-controls {
  186. position: absolute;
  187. top: 10px; right: 10px;
  188. z-index: 100;
  189. background: white;
  190. border-radius: 6px;
  191. box-shadow: 0 2px 8px rgba(0,0,0,0.12);
  192. padding: 4px;
  193. }
  194. .zoom-btn {
  195. display: flex;
  196. width: 28px; height: 28px;
  197. margin: 3px 0;
  198. border: 1px solid #e2e8f0;
  199. border-radius: 4px;
  200. background: white; cursor: pointer;
  201. align-items: center; justify-content: center;
  202. font-size: 14px; color: #475569;
  203. }
  204. .zoom-btn:hover { background: #f1f5f9; color: #0f172a; }
  205. /* ── 右键菜单 ── */
  206. .context-menu {
  207. position: absolute;
  208. display: none;
  209. background: white;
  210. border: 1px solid #e2e8f0;
  211. box-shadow: 0 4px 16px rgba(0,0,0,0.15);
  212. z-index: 200;
  213. border-radius: 6px;
  214. padding: 4px 0;
  215. min-width: 130px;
  216. }
  217. .context-menu-item {
  218. padding: 8px 14px;
  219. cursor: pointer;
  220. font-size: 13px;
  221. color: #334155;
  222. display: flex;
  223. align-items: center;
  224. gap: 6px;
  225. }
  226. .context-menu-item:hover { background: #f1f5f9; color: #0d6efd; }
  227. /* ── 操作提示浮层 ── */
  228. #collapse-hint-float {
  229. position: absolute;
  230. bottom: 12px; left: 50%;
  231. transform: translateX(-50%);
  232. background: rgba(245,158,11,0.92);
  233. color: white;
  234. padding: 6px 14px;
  235. border-radius: 20px;
  236. font-size: 12px;
  237. font-family: 'Microsoft YaHei', sans-serif;
  238. z-index: 300;
  239. pointer-events: none;
  240. display: none;
  241. white-space: nowrap;
  242. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  243. }
  244. </style>
  245. {% endblock %}
  246. {% block content %}
  247. <div class="d-flex justify-content-between align-items-center mb-2">
  248. <h2><i class="bi bi-layout-three-columns me-2"></i>世代分层家谱树</h2>
  249. <div class="d-flex gap-2">
  250. <div class="input-group" style="width:260px">
  251. <input id="genSearch" type="text" class="form-control form-control-sm" placeholder="输入成员名搜索定位">
  252. <button class="btn btn-sm btn-primary" onclick="searchMember()"><i class="bi bi-search"></i></button>
  253. </div>
  254. <a href="{{ url_for('tree') }}" class="btn btn-outline-secondary btn-sm">
  255. <i class="bi bi-diagram-3 me-1"></i>标准树状图
  256. </a>
  257. <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-secondary btn-sm">
  258. <i class="bi bi-printer me-1"></i>传统吊线图
  259. </a>
  260. </div>
  261. </div>
  262. <!-- ── 向上收紧工具栏 ── -->
  263. <div class="d-flex align-items-center gap-2 mb-2 p-2 rounded border bg-warning bg-opacity-10">
  264. <i class="bi bi-arrows-collapse text-warning"></i>
  265. <span class="small fw-semibold text-warning-emphasis">向上收紧:</span>
  266. <span class="small text-secondary">折叠第</span>
  267. <input id="collapseFrom" type="number" class="form-control form-control-sm text-center"
  268. style="width:62px" min="1" placeholder="起">
  269. <span class="small text-secondary">至</span>
  270. <input id="collapseTo" type="number" class="form-control form-control-sm text-center"
  271. style="width:62px" min="1" placeholder="止">
  272. <span class="small text-secondary">世(区间)</span>
  273. <button class="btn btn-sm btn-warning" onclick="applyCollapseFromInput()">收起</button>
  274. <button class="btn btn-sm btn-outline-secondary" onclick="clearAllCollapse()">展开全部</button>
  275. <span class="small text-muted ms-1">或在左侧标签栏点击两次
  276. <span style="display:inline-block;width:14px;height:14px;background:#fbbf24;border-radius:50%;vertical-align:middle;font-size:9px;line-height:14px;text-align:center;color:#fff;font-weight:900">─</span>
  277. 手柄折叠
  278. </span>
  279. </div>
  280. <div class="alert alert-light border small py-1 mb-2">
  281. <i class="bi bi-info-circle me-1"></i>
  282. 每行 = 同一世代。<strong>向上收紧</strong>:将中间世代压缩为虚线,保留两端节点。
  283. 男性方形<span style="display:inline-block;width:12px;height:12px;background:#EFF6FF;border:2px solid #3B82F6;border-radius:2px;vertical-align:middle;margin:0 3px"></span>,
  284. 女性圆形<span style="display:inline-block;width:12px;height:12px;background:#FDF2F8;border:2px solid #EC4899;border-radius:50%;vertical-align:middle;margin:0 3px"></span>,
  285. 橙色虚线=折叠穿透。右键可查看/编辑。
  286. </div>
  287. <div id="tree-gen-container">
  288. <!-- 左侧世代标签栏 -->
  289. <div id="gen-sidebar">
  290. <div id="gen-sidebar-title">世代</div>
  291. </div>
  292. <!-- 缩放控件 -->
  293. <div class="zoom-controls">
  294. <button class="zoom-btn" onclick="zoomIn()" title="放大"><i class="bi bi-plus"></i></button>
  295. <button class="zoom-btn" onclick="zoomOut()" title="缩小"><i class="bi bi-dash"></i></button>
  296. <button class="zoom-btn" onclick="zoomReset()" title="重置"><i class="bi bi-arrow-counterclockwise"></i></button>
  297. </div>
  298. <!-- 右键菜单 -->
  299. <div id="contextMenuGen" class="context-menu">
  300. <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
  301. <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
  302. <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
  303. </div>
  304. <!-- 操作提示浮层 -->
  305. <div id="collapse-hint-float"></div>
  306. <!-- 加载提示 -->
  307. <div id="gen-loading" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#94a3b8;z-index:5">
  308. <div class="spinner-border spinner-border-sm mb-2" role="status"></div>
  309. <div style="font-size:13px">加载中...</div>
  310. </div>
  311. </div>
  312. {% endblock %}
  313. {% block extra_js %}
  314. <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
  315. <script>
  316. if (typeof d3 === 'undefined') {
  317. var _ds = document.createElement('script');
  318. _ds.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
  319. _ds.onload = startInit;
  320. _ds.onerror = () => {
  321. document.getElementById('gen-loading').innerHTML =
  322. '<div class="text-danger small">D3.js 未加载,请检查网络。</div>';
  323. };
  324. document.head.appendChild(_ds);
  325. } else {
  326. startInit();
  327. }
  328. // ═══════════════════════════════════════════════════════════
  329. // 配置常量
  330. // ═══════════════════════════════════════════════════════════
  331. const CFG = {
  332. SIDEBAR_W: 150, // 侧边栏宽度
  333. ROW_H: 160, // 正常行高
  334. COLLAPSED_H: 80, // 折叠区间虚拟行高
  335. NODE_SPC: 100, // 水平节点间距
  336. NODE_R: 22, // 节点半径
  337. TOP_PAD: 60, // 顶部留白
  338. LEFT_PAD: 30, // 侧边栏右侧额外留白
  339. MAX_NAME: 6, // 节点显示最大字符数
  340. };
  341. // ═══════════════════════════════════════════════════════════
  342. // 全局状态
  343. // ═══════════════════════════════════════════════════════════
  344. let _rawData = null;
  345. let _mMap = null;
  346. let _minGen = 1;
  347. let _maxGen = 1;
  348. let _lineageRef = null;
  349. let _zoomBhv = null;
  350. let _svgEl = null;
  351. let _mainG = null;
  352. let _selMid = null;
  353. let _collapsedRanges = []; // [{start, end}, ...] 当前所有折叠区间
  354. let _collapseFirst = null; // 侧边栏手柄:第一次点击的 betweenGen 值
  355. const _ctxMenu = document.getElementById('contextMenuGen');
  356. window.addEventListener('click', () => { _ctxMenu.style.display = 'none'; });
  357. // ═══════════════════════════════════════════════════════════
  358. // 中文数字工具
  359. // ═══════════════════════════════════════════════════════════
  360. const _CN_D = ['零','一','二','三','四','五','六','七','八','九'];
  361. const _CN_U = ['','十','百','千','万'];
  362. function numToCN(n) {
  363. if (n <= 0) return '零';
  364. if (n <= 10) return n === 10 ? '十' : _CN_D[n];
  365. if (n < 20) return '十' + (n % 10 === 0 ? '' : _CN_D[n % 10]);
  366. let r = '', s = String(n);
  367. for (let i = 0; i < s.length; i++) {
  368. const d = +s[i], u = s.length - 1 - i;
  369. if (d === 0) { if (r && !r.endsWith('零') && i < s.length-1) r += '零'; }
  370. else r += _CN_D[d] + _CN_U[u];
  371. }
  372. return r.replace(/零+$/, '');
  373. }
  374. function cnToNum(s) {
  375. if (!s) return NaN;
  376. const dm = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
  377. const um = {'十':10,'百':100,'千':1000,'万':10000};
  378. let r = 0, t = 0;
  379. for (const ch of s.trim()) {
  380. if (dm[ch] !== undefined) t = dm[ch];
  381. else if (um[ch] !== undefined) { if (!t && um[ch]===10) t=1; r += t*um[ch]; t=0; }
  382. }
  383. return (r+t) || NaN;
  384. }
  385. function extractGen(str) {
  386. if (!str) return null;
  387. const m = String(str).match(/\d+/);
  388. return m ? parseInt(m[0]) : null;
  389. }
  390. function parseLineage(str) {
  391. if (!str) return [];
  392. return str.split(';').filter(s => s.trim()).map(item => {
  393. item = item.trim();
  394. const m = item.match(/^(.+?)第(.+?)代$/);
  395. if (m) return { place: m[1], num: cnToNum(m[2]) };
  396. return { place: '', num: NaN };
  397. });
  398. }
  399. function lineageLbl(refLineage, refGen, targetGen) {
  400. if (!refLineage || !refLineage.length) return [];
  401. const diff = targetGen - refGen;
  402. return refLineage.map(lg => {
  403. const n = lg.num + diff;
  404. if (isNaN(n) || n <= 0) return null;
  405. return (lg.place ? lg.place + '第' : '第') + numToCN(n) + '代';
  406. }).filter(Boolean);
  407. }
  408. // ═══════════════════════════════════════════════════════════
  409. // 折叠区间工具函数
  410. // ═══════════════════════════════════════════════════════════
  411. /** 计算 gen 在当前折叠状态下的 Y 坐标(内容坐标系) */
  412. function computeY(gen) {
  413. let y = CFG.TOP_PAD;
  414. let g = _minGen;
  415. while (g < gen) {
  416. const cr = _collapsedRanges.find(r => r.start <= g && g <= r.end);
  417. if (cr) {
  418. y += CFG.COLLAPSED_H; // 整个折叠区间只占 COLLAPSED_H
  419. g = cr.end + 1; // 跳过区间
  420. } else {
  421. y += CFG.ROW_H;
  422. g++;
  423. }
  424. }
  425. return y;
  426. }
  427. /** gen 是否在某个折叠区间内 */
  428. function isCollapsedGen(gen) {
  429. return _collapsedRanges.some(r => r.start <= gen && gen <= r.end);
  430. }
  431. /** 获取包含 gen 的折叠区间(没有则返回 null) */
  432. function getCollapseRange(gen) {
  433. return _collapsedRanges.find(r => r.start <= gen && gen <= r.end) || null;
  434. }
  435. /**
  436. * BFS:从 entryNode 的孩子出发,穿越折叠区间 cr,
  437. * 找到所有 gen > cr.end 的"出口节点"
  438. */
  439. function findExitNodes(entryNode, cr) {
  440. const exits = [];
  441. const visited = new Set([entryNode.id]);
  442. const queue = entryNode.children.filter(c => c.gen >= cr.start && c.gen <= cr.end);
  443. while (queue.length) {
  444. const cur = queue.shift();
  445. if (visited.has(cur.id)) continue;
  446. visited.add(cur.id);
  447. if (cur.gen > cr.end) {
  448. if (!exits.find(e => e.id === cur.id)) exits.push(cur);
  449. } else {
  450. cur.children.forEach(c => { if (!visited.has(c.id)) queue.push(c); });
  451. }
  452. }
  453. return exits;
  454. }
  455. // ═══════════════════════════════════════════════════════════
  456. // 折叠操作
  457. // ═══════════════════════════════════════════════════════════
  458. function applyCollapse(start, end) {
  459. if (start >= end) { alert('折叠区间无效,起始世代须小于结束世代'); return; }
  460. // 移除重叠的旧区间
  461. _collapsedRanges = _collapsedRanges.filter(r => r.end < start || r.start > end);
  462. _collapsedRanges.push({ start, end });
  463. renderGenTree(_rawData);
  464. }
  465. function expandRange(start, end) {
  466. _collapsedRanges = _collapsedRanges.filter(r => !(r.start === start && r.end === end));
  467. renderGenTree(_rawData);
  468. }
  469. function applyCollapseFromInput() {
  470. const from = parseInt(document.getElementById('collapseFrom').value);
  471. const to = parseInt(document.getElementById('collapseTo').value);
  472. if (isNaN(from) || isNaN(to)) { alert('请输入有效的起始和结束世代数字'); return; }
  473. applyCollapse(Math.min(from, to), Math.max(from, to));
  474. }
  475. function clearAllCollapse() {
  476. _collapsedRanges = [];
  477. renderGenTree(_rawData);
  478. }
  479. // ── 侧边栏手柄点击逻辑 ──
  480. function handleCollapseClick(betweenGen, el) {
  481. if (_collapseFirst === null) {
  482. // 第一次点击
  483. _collapseFirst = betweenGen;
  484. document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
  485. el.classList.add('first-selected');
  486. _showHint('✓ 已标记折叠起点(第' + numToCN(betweenGen) + '/'+ numToCN(betweenGen+1) +'世之间),再点一个位置完成收紧');
  487. } else {
  488. // 第二次点击
  489. const first = _collapseFirst;
  490. const second = betweenGen;
  491. _collapseFirst = null;
  492. document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
  493. _hideHint();
  494. const lo = Math.min(first, second) + 1;
  495. const hi = Math.max(first, second);
  496. if (lo > hi) { alert('请选择间距至少 1 代的两个位置'); return; }
  497. applyCollapse(lo, hi);
  498. }
  499. }
  500. function _showHint(msg) {
  501. const el = document.getElementById('collapse-hint-float');
  502. el.textContent = msg;
  503. el.style.display = 'block';
  504. }
  505. function _hideHint() {
  506. document.getElementById('collapse-hint-float').style.display = 'none';
  507. }
  508. // ═══════════════════════════════════════════════════════════
  509. // 入口
  510. // ═══════════════════════════════════════════════════════════
  511. function startInit() {
  512. fetch('/manager/api/tree_data')
  513. .then(r => r.json())
  514. .then(data => {
  515. _rawData = data;
  516. document.getElementById('gen-loading').style.display = 'none';
  517. renderGenTree(data);
  518. })
  519. .catch(() => {
  520. document.getElementById('gen-loading').innerHTML =
  521. '<div class="text-danger small">加载失败,请刷新重试。</div>';
  522. });
  523. }
  524. // ═══════════════════════════════════════════════════════════
  525. // 主渲染函数
  526. // ═══════════════════════════════════════════════════════════
  527. function renderGenTree(data) {
  528. const container = document.getElementById('tree-gen-container');
  529. d3.select('#tree-gen-container svg').remove();
  530. document.querySelectorAll('#gen-sidebar .gen-label-row, #gen-sidebar .gen-collapse-handle, #gen-sidebar .gen-collapsed-row').forEach(el => el.remove());
  531. const { members, relations } = data;
  532. if (!members || !members.length) {
  533. container.insertAdjacentHTML('beforeend',
  534. '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#94a3b8">暂无成员数据。</div>');
  535. return;
  536. }
  537. // ── 1. 构建成员 Map ──
  538. const mMap = {};
  539. members.forEach(m => {
  540. mMap[m.id] = {
  541. id: m.id, name: m.name || '', sname: m.simplified_name || '',
  542. sex: m.sex, family_rank: m.family_rank, nwg: m.name_word_generation,
  543. gen: extractGen(m.family_rank),
  544. children: [], spouses: [], _pCount: 0, _spouseOf: null,
  545. x: 0, y: 0,
  546. };
  547. });
  548. // ── 2. 处理关系 ──
  549. const pcRels = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
  550. const spRels = relations.filter(r => r.relation_type === 10);
  551. const hasFather = new Set(relations.filter(r => r.relation_type === 1).map(r => r.child_mid));
  552. // 找出有"入继"养父母的子女 ID 集合(这些人应显示在养父母名下,不显示在生父母名下)
  553. const hasAdoptiveParent = new Set(
  554. pcRels.filter(r => r.sub_relation_type === 3).map(r => r.child_mid)
  555. );
  556. pcRels.forEach(r => {
  557. if (r.relation_type === 2 && hasFather.has(r.child_mid)) return;
  558. // 出继子女(生父母侧,sub_relation_type=2)且已有养父母记录 → 跳过,不显示在生父母名下
  559. if (r.sub_relation_type === 2 && hasAdoptiveParent.has(r.child_mid)) return;
  560. const p = mMap[r.parent_mid], c = mMap[r.child_mid];
  561. if (!p || !c) return;
  562. if (!p.children.find(x => x.id === c.id)) p.children.push(c);
  563. c._pCount++;
  564. // 标记入继节点(养父母侧)
  565. if (r.sub_relation_type === 3) c._isAdoptedIn = true;
  566. });
  567. // 配偶关系不再在树中展示(spRels 不处理)
  568. // ── 3. 找根节点 ──
  569. let roots = Object.values(mMap).filter(m => !m._pCount);
  570. if (!roots.length) roots = [Object.values(mMap)[0]];
  571. // ── 4. 填充缺失世代 ──
  572. const gVis = new Set();
  573. function fillGen(node, pg) {
  574. if (gVis.has(node.id)) return;
  575. gVis.add(node.id);
  576. if (node.gen == null || isNaN(node.gen)) {
  577. if (node.nwg) {
  578. const parsed = parseLineage(node.nwg);
  579. if (parsed.length && !isNaN(parsed[0].num)) {
  580. node.gen = parsed[0].num;
  581. } else {
  582. node.gen = pg;
  583. }
  584. } else {
  585. node.gen = pg;
  586. }
  587. }
  588. node.children.forEach(c => fillGen(c, node.gen + 1));
  589. }
  590. roots.sort((a, b) => ((a.gen || 999) - (b.gen || 999)));
  591. roots.forEach(r => fillGen(r, r.gen || 1));
  592. // ── 5. 后序遍历分配 X 坐标 ──
  593. const xVis = new Set();
  594. let xCursor = 0;
  595. function assignX(node) {
  596. if (xVis.has(node.id)) return;
  597. xVis.add(node.id);
  598. if (!node.children.length) {
  599. node.x = xCursor + CFG.NODE_SPC / 2;
  600. xCursor += CFG.NODE_SPC;
  601. } else {
  602. node.children.forEach(c => assignX(c));
  603. const xs = node.children.map(c => c.x);
  604. node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
  605. const needed = Math.max(...xs) + CFG.NODE_SPC * 0.5;
  606. if (needed > xCursor) xCursor = needed;
  607. }
  608. }
  609. roots.forEach(r => { assignX(r); xCursor += CFG.NODE_SPC * 1.5; });
  610. // ── 6. 依世代计算 Y 坐标(考虑折叠区间) ──
  611. const allGens = Object.values(mMap).map(m => m.gen).filter(g => g != null && !isNaN(g));
  612. const minGen = allGens.length ? Math.min(...allGens) : 1;
  613. const maxGen = allGens.length ? Math.max(...allGens) : 1;
  614. _minGen = minGen; _maxGen = maxGen;
  615. const offsetX = CFG.SIDEBAR_W + CFG.LEFT_PAD;
  616. Object.values(mMap).forEach(m => {
  617. const g = (!isNaN(m.gen) && m.gen != null) ? m.gen : minGen;
  618. m.y = computeY(g); // 使用折叠感知的 Y 计算
  619. m.x = m.x + offsetX;
  620. });
  621. // ── 8. 找世系参考点 ──
  622. let lineageRef = null;
  623. Object.values(mMap).find(m => {
  624. if (!m.nwg) return false;
  625. const parsed = parseLineage(m.nwg);
  626. if (parsed.length && !isNaN(parsed[0].num)) { lineageRef = { gen: m.gen, lineage: parsed }; return true; }
  627. return false;
  628. });
  629. _lineageRef = lineageRef; _mMap = mMap;
  630. // ── 9. 创建 SVG ──
  631. const allX = Object.values(mMap).map(m => m.x);
  632. const allY = Object.values(mMap).map(m => m.y);
  633. const maxX = allX.length ? Math.max(...allX) : 800;
  634. const maxY = allY.length ? Math.max(...allY) : 400;
  635. const svgW = Math.max(container.offsetWidth || 800, maxX + 150);
  636. const svgH = Math.max(container.offsetHeight || 600, maxY + 120);
  637. _zoomBhv = d3.zoom().scaleExtent([0.04, 5]).on('zoom', e => {
  638. _mainG.attr('transform', e.transform);
  639. _updateSidebar(e.transform);
  640. });
  641. _svgEl = d3.select('#tree-gen-container')
  642. .append('svg')
  643. .attr('width', svgW).attr('height', svgH)
  644. .style('position', 'absolute').style('top', '0').style('left', '0').style('z-index', '2')
  645. .call(_zoomBhv);
  646. _mainG = _svgEl.append('g').attr('class', 'main-g');
  647. // ── 10. 行背景条纹 ──
  648. const stripeG = _mainG.append('g').attr('class', 'stripes');
  649. const fullW = Math.max(svgW, maxX + 300);
  650. {
  651. let g = minGen;
  652. let idx = 0;
  653. while (g <= maxGen) {
  654. const cr = _collapsedRanges.find(r => r.start === g);
  655. if (cr) {
  656. // 折叠区间:特殊背景
  657. const rowY = computeY(cr.start);
  658. stripeG.append('rect')
  659. .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
  660. .attr('y', rowY - CFG.COLLAPSED_H * 0.45)
  661. .attr('width', fullW).attr('height', CFG.COLLAPSED_H * 0.9)
  662. .attr('class', 'collapse-stripe');
  663. // 上下边界虚线
  664. [-1, 1].forEach(sign => {
  665. stripeG.append('line')
  666. .attr('x1', offsetX - CFG.LEFT_PAD * 0.5)
  667. .attr('y1', rowY + sign * CFG.COLLAPSED_H * 0.45)
  668. .attr('x2', fullW)
  669. .attr('y2', rowY + sign * CFG.COLLAPSED_H * 0.45)
  670. .attr('stroke', '#fbbf24').attr('stroke-width', 0.8)
  671. .attr('stroke-dasharray', '4,3');
  672. });
  673. g = cr.end + 1; idx++;
  674. } else {
  675. const rowY = computeY(g);
  676. if (idx % 2 === 0) {
  677. stripeG.append('rect')
  678. .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
  679. .attr('y', rowY - CFG.ROW_H * 0.46)
  680. .attr('width', fullW).attr('height', CFG.ROW_H * 0.92)
  681. .attr('fill', '#eef2ff').attr('opacity', 0.45);
  682. }
  683. if (g > minGen) {
  684. stripeG.append('line')
  685. .attr('x1', offsetX - CFG.LEFT_PAD * 0.5).attr('y1', rowY - CFG.ROW_H / 2)
  686. .attr('x2', fullW).attr('y2', rowY - CFG.ROW_H / 2)
  687. .attr('stroke', '#e2e8f0').attr('stroke-width', 0.8)
  688. .attr('stroke-dasharray', '6,5');
  689. }
  690. g++; idx++;
  691. }
  692. }
  693. }
  694. // ── 11. 正常亲子连线(跳过折叠区间中的节点) ──
  695. const linkG = _mainG.append('g').attr('class', 'links');
  696. Object.values(mMap).forEach(node => {
  697. if (isCollapsedGen(node.gen)) return; // 折叠区内的节点不画线
  698. const visChildren = node.children.filter(c => !isCollapsedGen(c.gen));
  699. if (!visChildren.length) return;
  700. const px = node.x, py = node.y;
  701. const midY = py + CFG.ROW_H * 0.40;
  702. const xs = visChildren.map(c => c.x);
  703. const minCX = Math.min(...xs), maxCX = Math.max(...xs);
  704. linkG.append('line').attr('x1', px).attr('y1', py + CFG.NODE_R + 2).attr('x2', px).attr('y2', midY).attr('class', 'link-parent');
  705. if (xs.length > 1) {
  706. linkG.append('line').attr('x1', minCX).attr('y1', midY).attr('x2', maxCX).attr('y2', midY).attr('class', 'link-parent');
  707. }
  708. visChildren.forEach(child => {
  709. linkG.append('line').attr('x1', child.x).attr('y1', midY).attr('x2', child.x).attr('y2', child.y - CFG.NODE_R - 2).attr('class', 'link-parent');
  710. });
  711. });
  712. // ── 11b. 折叠穿透虚线(Collapse connectors) ──
  713. const colConnG = _mainG.append('g').attr('class', 'collapse-conns');
  714. _collapsedRanges.forEach(cr => {
  715. Object.values(mMap).forEach(entryNode => {
  716. if (isCollapsedGen(entryNode.gen)) return;
  717. if (entryNode.gen >= cr.start) return;
  718. const hasChildInRange = entryNode.children.some(c => c.gen >= cr.start && c.gen <= cr.end);
  719. if (!hasChildInRange) return;
  720. const exits = findExitNodes(entryNode, cr);
  721. if (!exits.length) return;
  722. const px = entryNode.x, py = entryNode.y;
  723. const exitYs = exits.map(e => e.y);
  724. const exitY = Math.min(...exitYs); // 最近的出口行 Y
  725. const midY = py + (exitY - py) * 0.50; // 虚线中点
  726. const exitXs = exits.map(e => e.x);
  727. const allXs = [px, ...exitXs];
  728. const minBX = Math.min(...allXs), maxBX = Math.max(...allXs);
  729. // 父节点向下
  730. colConnG.append('line')
  731. .attr('x1', px).attr('y1', py + CFG.NODE_R + 2)
  732. .attr('x2', px).attr('y2', midY)
  733. .attr('class', 'link-collapse');
  734. // 横向汇聚线
  735. if (exits.length > 1 || Math.abs(px - exits[0].x) > 2) {
  736. colConnG.append('line')
  737. .attr('x1', minBX).attr('y1', midY)
  738. .attr('x2', maxBX).attr('y2', midY)
  739. .attr('class', 'link-collapse');
  740. }
  741. // 到各出口节点
  742. exits.forEach(exit => {
  743. colConnG.append('line')
  744. .attr('x1', exit.x).attr('y1', midY)
  745. .attr('x2', exit.x).attr('y2', exit.y - CFG.NODE_R - 2)
  746. .attr('class', 'link-collapse');
  747. });
  748. // 角标:× N代
  749. const count = cr.end - cr.start + 1;
  750. const bdgX = (minBX + maxBX) / 2;
  751. const bdgY = midY;
  752. const label = `↑收紧 ${count}代`;
  753. colConnG.append('rect')
  754. .attr('x', bdgX - 30).attr('y', bdgY - 11)
  755. .attr('width', 60).attr('height', 22)
  756. .attr('rx', 11).attr('fill', '#fffbeb')
  757. .attr('stroke', '#fbbf24').attr('stroke-width', 1.2);
  758. colConnG.append('text')
  759. .attr('x', bdgX).attr('y', bdgY + 1)
  760. .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
  761. .attr('font-size', '10px').attr('font-weight', '600')
  762. .attr('fill', '#d97706').attr('font-family', '"Microsoft YaHei", sans-serif')
  763. .text(label);
  764. });
  765. });
  766. // ── 12. 配偶连线已移除 ──
  767. // ── 13. 节点(跳过折叠区内) ──
  768. const nodeG = _mainG.append('g').attr('class', 'nodes');
  769. Object.values(mMap).forEach(m => {
  770. if (isCollapsedGen(m.gen)) return; // 跳过折叠区内节点
  771. const sexCls = m.sex === 1 ? 'node-male' : m.sex === 2 ? 'node-female' : 'node-unknown';
  772. const adoptedCls = m._isAdoptedIn ? ' node-adopted-in' : '';
  773. const ng = nodeG.append('g')
  774. .attr('class', `gen-node ${sexCls}${adoptedCls}`)
  775. .attr('data-mid', m.id)
  776. .attr('transform', `translate(${m.x},${m.y})`)
  777. .style('cursor', 'pointer')
  778. .on('contextmenu', event => {
  779. event.preventDefault();
  780. _selMid = m.id;
  781. const cr = container.getBoundingClientRect();
  782. _ctxMenu.style.display = 'block';
  783. _ctxMenu.style.left = (event.clientX - cr.left) + 'px';
  784. _ctxMenu.style.top = (event.clientY - cr.top) + 'px';
  785. })
  786. .on('dblclick', () => { if (m.id) window.location.href = `/manager/member_detail/${m.id}`; });
  787. const R = CFG.NODE_R;
  788. if (m.sex === 1) {
  789. ng.append('rect').attr('x', -R).attr('y', -R).attr('width', R*2).attr('height', R*2).attr('rx', 4).attr('ry', 4);
  790. } else {
  791. ng.append('circle').attr('r', R);
  792. }
  793. // 入继标签(节点正上方)
  794. if (m._isAdoptedIn) {
  795. ng.append('text').attr('class', 'node-adopt-label')
  796. .attr('x', 0).attr('y', -R - 3).attr('dy', '-0.2em')
  797. .attr('text-anchor', 'middle').text('入继');
  798. }
  799. const disp = m.sname || m.name;
  800. const short = disp.length > CFG.MAX_NAME ? disp.slice(0, CFG.MAX_NAME) + '…' : disp;
  801. const txt = ng.append('text').attr('class', 'node-name-text')
  802. .attr('x', 0).attr('y', R + 16).attr('dy', '0.15em').attr('text-anchor', 'middle').text(short);
  803. if (disp.length > CFG.MAX_NAME || (m.name && m.sname && m.name !== m.sname)) {
  804. const tip = m.name + (m.sname && m.name !== m.sname ? ` (${m.sname})` : '');
  805. txt.append('title').text(tip);
  806. ng.append('title').text(tip);
  807. }
  808. });
  809. // ── 14. 侧边栏 ──
  810. _buildSidebar(minGen, maxGen, lineageRef);
  811. _updateSidebar(d3.zoomIdentity);
  812. }
  813. // ═══════════════════════════════════════════════════════════
  814. // 侧边栏构建
  815. // ═══════════════════════════════════════════════════════════
  816. function _buildSidebar(minGen, maxGen, lineageRef) {
  817. const sidebar = document.getElementById('gen-sidebar');
  818. let g = minGen;
  819. while (g <= maxGen) {
  820. // 检查是否是折叠区间的起点
  821. const cr = _collapsedRanges.find(r => r.start === g);
  822. if (cr) {
  823. // 折叠行(点击展开)
  824. const count = cr.end - cr.start + 1;
  825. const div = document.createElement('div');
  826. div.className = 'gen-collapsed-row';
  827. div.dataset.start = cr.start;
  828. div.dataset.end = cr.end;
  829. div.innerHTML =
  830. `<span class="cr-title">⇕ 第${numToCN(cr.start)}~${numToCN(cr.end)}世</span>` +
  831. `<span class="cr-hint">(${count}代已收紧,点击展开)</span>`;
  832. div.onclick = () => expandRange(cr.start, cr.end);
  833. sidebar.appendChild(div);
  834. g = cr.end + 1;
  835. continue;
  836. }
  837. // 正常世代标签行
  838. const div = document.createElement('div');
  839. div.className = 'gen-label-row';
  840. div.dataset.gen = g;
  841. const numSpan = document.createElement('span');
  842. numSpan.className = 'gen-label-num';
  843. numSpan.textContent = `第${numToCN(g)}世`;
  844. div.appendChild(numSpan);
  845. if (lineageRef) {
  846. lineageLbl(lineageRef.lineage, lineageRef.gen, g).forEach(lbl => {
  847. const sp = document.createElement('span');
  848. sp.className = 'gen-label-lineage';
  849. sp.textContent = lbl;
  850. div.appendChild(sp);
  851. });
  852. }
  853. sidebar.appendChild(div);
  854. // 手柄:在当前代和下一代之间(如果下一代存在且未到 maxGen)
  855. if (g < maxGen) {
  856. const handle = document.createElement('div');
  857. handle.className = 'gen-collapse-handle';
  858. handle.dataset.between = g;
  859. handle.title = `点击标记折叠边界(第${numToCN(g)}~${numToCN(g+1)}世之间)`;
  860. handle.innerHTML = '<div class="h-line"><div class="h-btn">─</div></div>';
  861. handle.addEventListener('click', function(e) {
  862. e.stopPropagation();
  863. handleCollapseClick(parseInt(this.dataset.between), this);
  864. });
  865. sidebar.appendChild(handle);
  866. }
  867. g++;
  868. }
  869. }
  870. // ═══════════════════════════════════════════════════════════
  871. // 侧边栏位置同步
  872. // ═══════════════════════════════════════════════════════════
  873. function _updateSidebar(transform) {
  874. const k = transform.k, ty = transform.y;
  875. // 世代标签行
  876. document.querySelectorAll('#gen-sidebar .gen-label-row').forEach(el => {
  877. const g = parseInt(el.dataset.gen);
  878. el.style.top = (computeY(g) * k + ty) + 'px';
  879. });
  880. // 折叠手柄(在 gen g 和 g+1 之间的中点)
  881. document.querySelectorAll('#gen-sidebar .gen-collapse-handle').forEach(el => {
  882. const bg = parseInt(el.dataset.between);
  883. const y = (computeY(bg) + computeY(bg + 1)) / 2;
  884. el.style.top = (y * k + ty) + 'px';
  885. });
  886. // 折叠区间行(居中于折叠区间虚拟行)
  887. document.querySelectorAll('#gen-sidebar .gen-collapsed-row').forEach(el => {
  888. const start = parseInt(el.dataset.start);
  889. // 折叠区间起始的内容 Y + 半个虚拟行高
  890. const y = computeY(start) + CFG.COLLAPSED_H / 2;
  891. el.style.top = (y * k + ty) + 'px';
  892. });
  893. }
  894. // ═══════════════════════════════════════════════════════════
  895. // 缩放控件
  896. // ═══════════════════════════════════════════════════════════
  897. function zoomIn() { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 1.35); }
  898. function zoomOut() { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 0.75); }
  899. function zoomReset() { if (_svgEl) _svgEl.transition().duration(300).call(_zoomBhv.transform, d3.zoomIdentity); }
  900. // ═══════════════════════════════════════════════════════════
  901. // 右键菜单
  902. // ═══════════════════════════════════════════════════════════
  903. function menuAction(type) {
  904. switch(type) {
  905. case 'detail': if (_selMid) window.location.href = `/manager/member_detail/${_selMid}`; break;
  906. case 'edit': if (_selMid) window.location.href = `/manager/edit_member/${_selMid}`; break;
  907. case 'add': window.location.href = '/manager/add_member'; break;
  908. }
  909. }
  910. // ═══════════════════════════════════════════════════════════
  911. // 搜索
  912. // ═══════════════════════════════════════════════════════════
  913. function searchMember() {
  914. const term = document.getElementById('genSearch').value.trim().toLowerCase();
  915. if (!term) return;
  916. if (!_rawData || !_rawData.members) { alert('数据未加载完成,请稍候再试'); return; }
  917. const matches = _rawData.members.filter(m =>
  918. (m.name || '').toLowerCase().includes(term) ||
  919. (m.simplified_name || '').toLowerCase().includes(term)
  920. );
  921. if (!matches.length) { alert('未找到匹配成员'); return; }
  922. const target = matches[0];
  923. const node = _mMap && _mMap[target.id];
  924. if (!node || !_svgEl) return;
  925. const cont = document.getElementById('tree-gen-container');
  926. const scale = 1.6;
  927. _svgEl.transition().duration(800).call(
  928. _zoomBhv.transform,
  929. d3.zoomIdentity.translate(cont.clientWidth/2 - node.x*scale, cont.clientHeight/2 - node.y*scale).scale(scale)
  930. );
  931. _mainG.selectAll('.gen-node').filter(function() {
  932. return d3.select(this).attr('data-mid') == target.id;
  933. }).classed('node-highlight', true);
  934. setTimeout(() => { _mainG.selectAll('.gen-node').classed('node-highlight', false); }, 2000);
  935. }
  936. document.getElementById('genSearch').addEventListener('keypress', e => {
  937. if (e.key === 'Enter') searchMember();
  938. });
  939. </script>
  940. {% endblock %}