tree_classic.html 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>传统吊线图导出 - 家谱管理系统</title>
  6. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  7. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
  8. <style>
  9. body {
  10. background-color: #f0f2f5;
  11. padding: 20px;
  12. font-family: "Microsoft YaHei", "SimSun", serif;
  13. }
  14. .toolbar {
  15. background: white;
  16. padding: 15px;
  17. border-radius: 8px;
  18. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  19. margin-bottom: 20px;
  20. display: flex;
  21. justify-content: space-between;
  22. align-items: center;
  23. }
  24. .export-container {
  25. margin: 0 auto;
  26. display: flex;
  27. flex-direction: column;
  28. gap: 40px;
  29. align-items: center;
  30. }
  31. .page-block {
  32. background-color: #fff;
  33. padding: 40px 60px;
  34. border: 1px solid #ddd;
  35. box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  36. position: relative;
  37. /* 类似A4纸横向或纵向,这里允许根据内容自适应宽度 */
  38. min-width: 1000px;
  39. overflow-x: auto;
  40. }
  41. .page-header {
  42. text-align: center;
  43. margin-bottom: 30px;
  44. position: relative;
  45. }
  46. .page-title {
  47. font-size: 28px;
  48. font-weight: bold;
  49. letter-spacing: 4px;
  50. font-family: "KaiTi", "SimSun", serif;
  51. }
  52. .page-subtitle {
  53. position: absolute;
  54. right: 0;
  55. top: 10px;
  56. font-size: 14px;
  57. color: #333;
  58. }
  59. .page-left-title {
  60. position: absolute;
  61. left: 0;
  62. top: 10px;
  63. font-size: 14px;
  64. color: #333;
  65. }
  66. /* SVG 样式 */
  67. .tree-svg {
  68. display: block;
  69. margin: 0 auto;
  70. overflow: visible;
  71. }
  72. .line {
  73. fill: none;
  74. stroke: #333;
  75. stroke-width: 1.5px;
  76. shape-rendering: crispEdges;
  77. }
  78. .gen-text {
  79. font-size: 16px;
  80. fill: #d32f2f;
  81. font-weight: bold;
  82. font-family: "KaiTi", "SimSun", serif;
  83. }
  84. .node-rect {
  85. fill: #fff;
  86. }
  87. .node-text {
  88. font-size: 18px;
  89. font-weight: bold;
  90. fill: #000;
  91. font-family: "KaiTi", "SimSun", serif;
  92. }
  93. .node-text.selected-root-text {
  94. fill: #0d6efd;
  95. font-weight: 700;
  96. }
  97. .node-selector {
  98. cursor: pointer;
  99. }
  100. .node-selector-box {
  101. fill: #fff;
  102. stroke: #999;
  103. stroke-width: 1.2px;
  104. rx: 2px;
  105. ry: 2px;
  106. }
  107. .node-selector-box.checked {
  108. fill: #0d6efd;
  109. stroke: #0d6efd;
  110. }
  111. .node-selector-mark {
  112. font-size: 10px;
  113. fill: #fff;
  114. font-family: Arial, sans-serif;
  115. pointer-events: none;
  116. }
  117. .selected-member-chip {
  118. display: inline-flex;
  119. align-items: center;
  120. gap: 6px;
  121. padding: 4px 8px;
  122. border: 1px solid #ced4da;
  123. border-radius: 12px;
  124. margin: 4px 6px 0 0;
  125. font-size: 13px;
  126. background: #f8f9fa;
  127. }
  128. .selected-member-chip button {
  129. border: none;
  130. background: transparent;
  131. color: #dc3545;
  132. font-size: 14px;
  133. line-height: 1;
  134. padding: 0 2px;
  135. cursor: pointer;
  136. }
  137. .node-spouse {
  138. font-size: 12px;
  139. fill: #555;
  140. font-family: "KaiTi", "SimSun", serif;
  141. }
  142. .node-mark {
  143. font-size: 12px;
  144. fill: #d32f2f;
  145. font-family: "Microsoft YaHei", sans-serif;
  146. }
  147. .rel-text {
  148. font-size: 12px;
  149. fill: #666;
  150. font-family: "KaiTi", "SimSun", serif;
  151. }
  152. </style>
  153. </head>
  154. <body>
  155. <div class="container-fluid">
  156. <div class="toolbar d-print-none">
  157. <div>
  158. <a href="/manager/tree" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left"></i> 返回</a>
  159. <h4 class="d-inline mb-0 align-middle">传统吊线图预览</h4>
  160. </div>
  161. <div>
  162. <button class="btn btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
  163. <i class="bi bi-person-lines-fill"></i> 选择起始人员
  164. </button>
  165. <button id="btnExport" class="btn btn-primary"><i class="bi bi-download"></i> 导出为图片</button>
  166. </div>
  167. </div>
  168. <div id="loading" class="text-center py-5">
  169. <div class="spinner-border text-primary" role="status"></div>
  170. <p class="mt-2 text-muted">正在加载并生成排版...</p>
  171. </div>
  172. <div class="export-container" id="exportArea">
  173. <!-- 页面块将通过JS生成插入到这里 -->
  174. </div>
  175. </div>
  176. <!-- 人员选择 Modal -->
  177. <div class="modal fade" id="memberSelectModal" tabindex="-1" aria-hidden="true">
  178. <div class="modal-dialog modal-dialog-scrollable">
  179. <div class="modal-content">
  180. <div class="modal-header">
  181. <h5 class="modal-title">选择要导出的起始人员(祖先)</h5>
  182. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  183. </div>
  184. <div class="modal-body">
  185. <div class="mb-3">
  186. <div class="small text-muted mb-1">已勾选起始人员(可删除)</div>
  187. <div id="selectedMembersPanel"></div>
  188. </div>
  189. <input type="text" id="memberSearch" class="form-control mb-3" placeholder="搜索姓名...">
  190. <div class="mb-2">
  191. <button class="btn btn-sm btn-outline-secondary" onclick="selectAllMembers(true)">全选</button>
  192. <button class="btn btn-sm btn-outline-secondary" onclick="selectAllMembers(false)">全不选</button>
  193. <span class="text-muted small ms-2">可搜索增加人员;已勾选人员会显示在上方</span>
  194. </div>
  195. <div id="memberList" class="list-group">
  196. <!-- 成员复选框列表 -->
  197. </div>
  198. </div>
  199. <div class="modal-footer">
  200. <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
  201. <button type="button" class="btn btn-primary" onclick="drawSelectedTree()">开始绘制</button>
  202. </div>
  203. </div>
  204. </div>
  205. </div>
  206. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
  207. <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
  208. <script>
  209. // 配置常量
  210. const CONFIG = {
  211. MAX_GEN_PER_PAGE: 10, // 每页最多显示的代数
  212. X_STEP: 55, // 列宽
  213. Y_STEP: 160, // 行高 (代与代之间的距离)
  214. NODE_WIDTH: 24, // 名字竖排的估计宽度
  215. NODE_HEIGHT: 80, // 名字竖排的估计高度
  216. MARGIN_LEFT: 120, // 左侧世系世代标记留白
  217. MARGIN_TOP: 20, // 顶部留白
  218. MARGIN_BOTTOM: 40, // 底部留白
  219. LINE_MID_OFFSET: 40 // 横线距离父节点底部的距离
  220. };
  221. let totalPagesCount = 0;
  222. let allMembersData = [];
  223. let allRelationsData = [];
  224. let selectedRootIdsSet = new Set();
  225. let showChartSelectors = true; // 图中快速勾选框显示开关
  226. const TRAD_TO_SIMP = {
  227. '學':'学','國':'国','萬':'万','寶':'宝','興':'兴','華':'华','會':'会',
  228. '葉':'叶','藝':'艺','號':'号','處':'处','見':'见','視':'视','語':'语',
  229. '貝':'贝','車':'车','長':'长','門':'门','韋':'韦','頁':'页','風':'风',
  230. '飛':'飞','馬':'马','魚':'鱼','鳥':'鸟','麥':'麦','黃':'黄','齊':'齐',
  231. '齒':'齿','龍':'龙','龜':'龟','壽':'寿','榮':'荣','愛':'爱','慶':'庆',
  232. '衛':'卫','賢':'贤','義':'义','禮':'礼','樂':'乐','靈':'灵','滅':'灭',
  233. '氣':'气','嚴':'严','銳':'锐','優':'优','楊':'杨','吳':'吴','銀':'银',
  234. '劉':'刘','陳':'陈','張':'张','趙':'赵','鄭':'郑','錢':'钱','許':'许',
  235. '鄧':'邓','蕭':'萧','謝':'谢','鍾':'钟','盧':'卢','譚':'谭','廖':'廖',
  236. '範':'范','蘇':'苏','薑':'姜','傳':'传','紀':'纪','開':'开','書':'书'
  237. };
  238. function toSimplifiedChinese(text) {
  239. return Array.from(text).map(ch => TRAD_TO_SIMP[ch] || ch).join('');
  240. }
  241. document.addEventListener('DOMContentLoaded', function() {
  242. loadTreeData();
  243. // Search filter for member list
  244. document.getElementById('memberSearch').addEventListener('input', function() {
  245. const term = this.value.trim().toLowerCase();
  246. const simplifiedTerm = toSimplifiedChinese(term);
  247. const labels = document.querySelectorAll('#memberList label');
  248. labels.forEach(lbl => {
  249. const searchData = lbl.dataset.search || lbl.textContent.toLowerCase();
  250. const visible = searchData.includes(term) || searchData.includes(simplifiedTerm);
  251. lbl.style.display = visible ? 'block' : 'none';
  252. });
  253. });
  254. document.getElementById('memberSelectModal').addEventListener('show.bs.modal', function() {
  255. syncCheckboxesFromSelectedSet();
  256. renderSelectedMembersPanel();
  257. });
  258. document.getElementById('btnExport').addEventListener('click', function() {
  259. const element = document.getElementById('exportArea');
  260. const btn = this;
  261. const originalHtml = btn.innerHTML;
  262. btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在渲染...';
  263. btn.disabled = true;
  264. setTimeout(() => {
  265. html2canvas(element, {
  266. scale: 2,
  267. backgroundColor: '#f0f2f5',
  268. logging: false
  269. }).then(canvas => {
  270. const imgData = canvas.toDataURL('image/jpeg', 0.9);
  271. const link = document.createElement('a');
  272. link.download = '家谱吊线图_' + new Date().getTime() + '.jpg';
  273. link.href = imgData;
  274. link.click();
  275. btn.innerHTML = originalHtml;
  276. btn.disabled = false;
  277. }).catch(err => {
  278. console.error('导出失败:', err);
  279. alert('导出失败,请重试。');
  280. btn.innerHTML = originalHtml;
  281. btn.disabled = false;
  282. });
  283. }, 500);
  284. });
  285. });
  286. function selectAllMembers(checked) {
  287. document.querySelectorAll('.member-checkbox').forEach(cb => {
  288. if (cb.parentElement.style.display !== 'none') {
  289. cb.checked = checked;
  290. }
  291. });
  292. syncSelectedSetFromCheckboxes();
  293. renderSelectedMembersPanel();
  294. }
  295. function syncSelectedSetFromCheckboxes() {
  296. selectedRootIdsSet = new Set(
  297. Array.from(document.querySelectorAll('.member-checkbox:checked'))
  298. .map(cb => parseInt(cb.value))
  299. .filter(v => !Number.isNaN(v))
  300. );
  301. }
  302. function syncCheckboxesFromSelectedSet() {
  303. document.querySelectorAll('.member-checkbox').forEach(cb => {
  304. const id = parseInt(cb.value);
  305. cb.checked = selectedRootIdsSet.has(id);
  306. });
  307. }
  308. function renderSelectedMembersPanel() {
  309. const panel = document.getElementById('selectedMembersPanel');
  310. if (!panel) return;
  311. const selectedMembers = allMembersData.filter(m => selectedRootIdsSet.has(m.id));
  312. if (selectedMembers.length === 0) {
  313. panel.innerHTML = '<span class="text-muted small">当前未勾选任何人员</span>';
  314. return;
  315. }
  316. panel.innerHTML = selectedMembers.map(m => `
  317. <span class="selected-member-chip">
  318. <span>${m.name || ('ID:' + m.id)}</span>
  319. <button type="button" data-remove-id="${m.id}" title="移除">×</button>
  320. </span>
  321. `).join('');
  322. panel.querySelectorAll('button[data-remove-id]').forEach(btn => {
  323. btn.addEventListener('click', function() {
  324. const id = parseInt(this.dataset.removeId);
  325. if (Number.isNaN(id)) return;
  326. selectedRootIdsSet.delete(id);
  327. syncCheckboxesFromSelectedSet();
  328. renderSelectedMembersPanel();
  329. });
  330. });
  331. }
  332. function drawSelectedTree() {
  333. const selectedIds = Array.from(document.querySelectorAll('.member-checkbox:checked')).map(cb => parseInt(cb.value));
  334. if (selectedIds.length === 0) {
  335. alert('请至少选择一位起始人员。');
  336. return;
  337. }
  338. selectedRootIdsSet = new Set(selectedIds);
  339. showChartSelectors = false; // 点击“开始绘制”后进入正式输出模式,不显示勾选框
  340. // Close modal
  341. const modalEl = document.getElementById('memberSelectModal');
  342. const modal = bootstrap.Modal.getInstance(modalEl);
  343. if(modal) modal.hide();
  344. document.getElementById('loading').style.display = 'block';
  345. document.getElementById('exportArea').innerHTML = '';
  346. // Allow UI to update before heavy computation
  347. setTimeout(() => {
  348. buildAndRenderPages(allMembersData, allRelationsData, selectedIds);
  349. }, 100);
  350. }
  351. async function loadTreeData() {
  352. try {
  353. const response = await fetch('/manager/api/tree_data');
  354. const data = await response.json();
  355. if (data.error) return alert('获取数据失败: ' + data.error);
  356. allMembersData = data.members;
  357. allRelationsData = data.relations;
  358. // Populate member list in modal
  359. const listHtml = allMembersData.map(m => {
  360. const gen = m.family_rank ? ` (排行/代数: ${m.family_rank})` : '';
  361. const sname = m.simplified_name ? `(${m.simplified_name})` : '';
  362. const searchText = `${m.name || ''} ${m.simplified_name || ''} ${m.family_rank || ''}`.toLowerCase();
  363. return `
  364. <label class="list-group-item" data-search="${searchText}">
  365. <input class="form-check-input me-1 member-checkbox" type="checkbox" value="${m.id}">
  366. ${m.name}${sname}${gen}
  367. </label>
  368. `;
  369. }).join('');
  370. document.getElementById('memberList').innerHTML = listHtml;
  371. document.querySelectorAll('.member-checkbox').forEach(cb => {
  372. cb.addEventListener('change', syncSelectedSetFromCheckboxes);
  373. cb.addEventListener('change', renderSelectedMembersPanel);
  374. });
  375. // default build using empty selection (will find absolute roots)
  376. buildAndRenderPages(data.members, data.relations, []);
  377. } catch (error) {
  378. console.error(error);
  379. document.getElementById('loading').innerHTML = '<span class="text-danger">加载失败,请检查网络并重试。</span>';
  380. }
  381. }
  382. function extractGen(rankStr) {
  383. if (!rankStr) return null;
  384. const match = String(rankStr).match(/\d+/);
  385. return match ? parseInt(match[0]) : null;
  386. }
  387. // ===== 世系世代:中文数字转换 =====
  388. const CN_DIGITS = ['零','一','二','三','四','五','六','七','八','九'];
  389. const CN_UNITS = ['','十','百','千','万'];
  390. function numToChinese(n) {
  391. if (n <= 0) return '零';
  392. if (n <= 10) return n === 10 ? '十' : CN_DIGITS[n];
  393. if (n < 20) return '十' + (n % 10 === 0 ? '' : CN_DIGITS[n % 10]);
  394. let result = '';
  395. const str = String(n);
  396. const len = str.length;
  397. for (let i = 0; i < len; i++) {
  398. const d = parseInt(str[i]);
  399. const unitIdx = len - 1 - i;
  400. if (d === 0) {
  401. if (result && !result.endsWith('零') && i < len - 1) result += '零';
  402. } else {
  403. result += CN_DIGITS[d] + CN_UNITS[unitIdx];
  404. }
  405. }
  406. return result.replace(/零+$/, '');
  407. }
  408. function chineseToNum(s) {
  409. if (!s) return NaN;
  410. s = s.trim();
  411. const digitMap = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
  412. const unitMap = {'十':10,'百':100,'千':1000,'万':10000};
  413. let result = 0, temp = 0;
  414. for (let i = 0; i < s.length; i++) {
  415. const ch = s[i];
  416. if (digitMap[ch] !== undefined) {
  417. temp = digitMap[ch];
  418. } else if (unitMap[ch] !== undefined) {
  419. if (temp === 0 && unitMap[ch] === 10) temp = 1;
  420. result += temp * unitMap[ch];
  421. temp = 0;
  422. }
  423. }
  424. result += temp;
  425. return result || NaN;
  426. }
  427. function parseLineageGenerations(str) {
  428. if (!str) return [];
  429. return str.split(';').filter(s => s.trim()).map(item => {
  430. item = item.trim();
  431. const m = item.match(/^(.+?)第(.+?)代$/);
  432. if (m) {
  433. return { place: m[1], num: chineseToNum(m[2]), raw: item };
  434. }
  435. return { place: '', num: NaN, raw: item };
  436. });
  437. }
  438. function formatLineageGeneration(place, num) {
  439. if (isNaN(num) || num <= 0) return '';
  440. return place + '第' + numToChinese(num) + '代';
  441. }
  442. function computeLineageForGen(refLineage, refGen, targetGen) {
  443. if (!refLineage || refLineage.length === 0) return [];
  444. const diff = targetGen - refGen;
  445. return refLineage.map(lg => ({
  446. place: lg.place,
  447. num: lg.num + diff,
  448. formatted: formatLineageGeneration(lg.place, lg.num + diff)
  449. })).filter(lg => lg.num > 0);
  450. }
  451. function buildAndRenderPages(members, relations, selectedRootIds) {
  452. // 1. 构建树结构
  453. const memberMap = {};
  454. // Deep clone members to avoid state pollution between multiple drawings
  455. const clonedMembers = members.map(m => ({...m, children: [], spouses: []}));
  456. clonedMembers.forEach(m => {
  457. m.gen = extractGen(m.family_rank);
  458. memberMap[m.id] = m;
  459. });
  460. // 找出有"入继"养父母的子女 ID 集合
  461. const adoptiveChildIds = new Set(
  462. relations.filter(r => (r.relation_type === 1 || r.relation_type === 2) && r.sub_relation_type === 3)
  463. .map(r => r.child_mid)
  464. );
  465. relations.forEach(rel => {
  466. const parent = memberMap[rel.parent_mid];
  467. const child = memberMap[rel.child_mid];
  468. if (!parent || !child) return;
  469. if (rel.relation_type === 1 || rel.relation_type === 2) {
  470. // 出继子女(生父母侧)且已有养父母记录 → 跳过,不显示在生父母名下
  471. if (rel.sub_relation_type === 2 && adoptiveChildIds.has(rel.child_mid)) return;
  472. parent.children.push(child);
  473. child._hasParent = true;
  474. if (rel.sub_relation_type === 3) child._isAdoptedIn = true;
  475. }
  476. // relation_type === 10(夫妻)不处理,不在树中展示配偶
  477. });
  478. // 寻找根节点并推断代数
  479. let roots = [];
  480. if (selectedRootIds && selectedRootIds.length > 0) {
  481. roots = selectedRootIds.map(id => memberMap[id]).filter(m => m);
  482. } else {
  483. roots = clonedMembers.filter(m => !m._hasParent && !m._isSpouse);
  484. }
  485. // 递归填充缺失的代数
  486. function fillGen(node, currentGen) {
  487. if (node.gen === null || isNaN(node.gen)) {
  488. node.gen = currentGen;
  489. }
  490. node.children.forEach(c => fillGen(c, node.gen + 1));
  491. }
  492. // 如果根节点也没有代数,默认给个1
  493. roots.forEach(r => fillGen(r, r.gen || 1));
  494. // 按照世代对roots进行排序,优先显示最老的祖先
  495. roots.sort((a, b) => a.gen - b.gen);
  496. // 查找世系世代参考点:找到任意一个有 name_word_generation 的人
  497. let lineageRef = null;
  498. function findLineageRef(node) {
  499. if (lineageRef) return;
  500. if (node.name_word_generation) {
  501. const parsed = parseLineageGenerations(node.name_word_generation);
  502. if (parsed.length > 0 && !isNaN(parsed[0].num)) {
  503. lineageRef = { gen: node.gen, lineage: parsed };
  504. }
  505. }
  506. if (!lineageRef && node.children) {
  507. node.children.forEach(c => findLineageRef(c));
  508. }
  509. }
  510. roots.forEach(r => findLineageRef(r));
  511. document.getElementById('loading').style.display = 'none';
  512. const container = document.getElementById('exportArea');
  513. if (roots.length === 0) {
  514. container.innerHTML = '<div class="text-muted">暂无家谱数据。</div>';
  515. return;
  516. }
  517. // 2. 分页切块逻辑
  518. const pageQueue = [];
  519. // 将第一批 roots 放入第一页
  520. if (roots.length > 0) {
  521. let minGen = Math.min(...roots.map(r => r.gen));
  522. pageQueue.push({
  523. nodes: roots,
  524. startGen: minGen,
  525. leftTitle: '',
  526. pageId: 1
  527. });
  528. }
  529. let pagesRendered = [];
  530. let nextPageId = 2;
  531. while(pageQueue.length > 0) {
  532. let currentJob = pageQueue.shift();
  533. // 克隆当前块的节点树,遇到超过 MAX_GEN_PER_PAGE 的裁剪并生成新任务
  534. let maxGenForThisPage = currentJob.startGen + CONFIG.MAX_GEN_PER_PAGE - 1;
  535. let blockNodes = cloneAndClip(currentJob.nodes, maxGenForThisPage, pageQueue);
  536. pagesRendered.push({
  537. id: currentJob.pageId,
  538. nodes: blockNodes,
  539. startGen: currentJob.startGen,
  540. endGen: maxGenForThisPage,
  541. leftTitle: currentJob.leftTitle,
  542. lineageRef: lineageRef
  543. });
  544. }
  545. totalPagesCount = pagesRendered.length;
  546. // 3. 渲染页面
  547. pagesRendered.forEach((page, index) => {
  548. const pageHtml = renderPageSVG(page, index + 1, totalPagesCount);
  549. container.insertAdjacentHTML('beforeend', pageHtml);
  550. });
  551. bindQuickSelectOnChartNames();
  552. syncCheckboxesFromSelectedSet();
  553. // Function to clip deep branches
  554. function cloneAndClip(nodes, maxGen, queue) {
  555. return nodes.map(n => {
  556. let clone = { ...n };
  557. // 当当前节点已达到本页最大代数,且还有子节点时,截断并送入下一页
  558. if (clone.gen >= maxGen && clone.children && clone.children.length > 0) {
  559. clone.hasNextPage = true;
  560. clone.nextPageLink = nextPageId;
  561. queue.push({
  562. nodes: clone.children,
  563. startGen: clone.gen + 1,
  564. leftTitle: `上接 ${clone.name}`,
  565. pageId: nextPageId
  566. });
  567. nextPageId++;
  568. clone.children = [];
  569. } else if (clone.children && clone.children.length > 0) {
  570. clone.children = cloneAndClip(clone.children, maxGen, queue);
  571. }
  572. return clone;
  573. });
  574. }
  575. }
  576. // SVG 生成核心逻辑
  577. function renderPageSVG(page, pageNum, totalPages) {
  578. let currentX = 0;
  579. const nodesFlat = [];
  580. const lines = [];
  581. // 1. 布局计算 (核心算法: 父亲对齐长子,从右向左排版 RTL)
  582. function layout(node, depthY) {
  583. if (!node.children || node.children.length === 0) {
  584. node.x = currentX;
  585. node.y = depthY;
  586. currentX -= CONFIG.X_STEP; // 向左递减,实现从右到左排版
  587. } else {
  588. node.children.forEach(child => layout(child, depthY + 1));
  589. node.x = node.children[0].x; // 父亲与长子对齐
  590. node.y = depthY;
  591. }
  592. nodesFlat.push(node);
  593. }
  594. // 对 page.nodes 进行布局
  595. page.nodes.forEach(root => layout(root, 0));
  596. // 获取所需的实际尺寸
  597. const minX = currentX; // 因为向左排布,最小X是负数
  598. const maxDepth = Math.max(...nodesFlat.map(n => n.y), 0);
  599. // 坐标平移,把所有负数X转为正数,并在右侧留白
  600. const offsetX = Math.abs(minX) + CONFIG.MARGIN_LEFT + 50;
  601. const svgWidth = offsetX + 100; // 总宽度
  602. const svgHeight = (maxDepth + 1) * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.MARGIN_BOTTOM;
  603. // 2. 构建连线
  604. nodesFlat.forEach(node => {
  605. if (node.children && node.children.length > 0) {
  606. const firstChild = node.children[0];
  607. const lastChild = node.children[node.children.length - 1];
  608. // 从父亲到底部横线
  609. const pX = node.x + offsetX;
  610. const pY = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.NODE_HEIGHT;
  611. const midY = pY + CONFIG.LINE_MID_OFFSET;
  612. lines.push(`<line class="line" x1="${pX}" y1="${pY}" x2="${pX}" y2="${midY}" />`);
  613. // 绘制子节点水平连线
  614. const firstX = firstChild.x + offsetX;
  615. const lastX = lastChild.x + offsetX;
  616. if (firstX !== lastX) {
  617. lines.push(`<line class="line" x1="${firstX}" y1="${midY}" x2="${lastX}" y2="${midY}" />`);
  618. }
  619. // 绘制连接到每个子节点的垂线
  620. node.children.forEach((child, i) => {
  621. const cX = child.x + offsetX;
  622. const cY = child.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  623. lines.push(`<line class="line" x1="${cX}" y1="${midY}" x2="${cX}" y2="${cY}" />`);
  624. // 右侧标记“长子”、“次子” (因为从右到左,字应该写在左边或者右边)
  625. // 传统家谱写在线右侧
  626. const relLabels = ['长子', '次子', '三子', '四子', '五子', '六子', '七子', '八子'];
  627. let relLabel = relLabels[i] || '子';
  628. if(child.sex === 2) relLabel = '女';
  629. // 竖排显示长子、次子
  630. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 15}">${relLabel[0]}</text>`);
  631. if(relLabel[1]) {
  632. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 28}">${relLabel[1]}</text>`);
  633. }
  634. });
  635. }
  636. });
  637. // 3. 构建节点文字
  638. const nodesHtml = nodesFlat.map(node => {
  639. const nx = node.x + offsetX;
  640. const ny = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  641. const isSelectedRoot = selectedRootIdsSet.has(node.id);
  642. let html = '';
  643. // 姓名竖排处理 (简单切分文字为数组)
  644. let nameArr = Array.from(node.name || '未知');
  645. // 入继节点:在姓名上方加"入继"小字标注
  646. if (node._isAdoptedIn) {
  647. html += `<text class="node-text" x="${nx}" y="${ny}" text-anchor="middle" font-size="8" fill="#b45309" stroke="white" stroke-width="2" paint-order="stroke">入</text>`;
  648. html += `<text class="node-text" x="${nx}" y="${ny + 9}" text-anchor="middle" font-size="8" fill="#b45309" stroke="white" stroke-width="2" paint-order="stroke">继</text>`;
  649. }
  650. nameArr.forEach((char, i) => {
  651. const yOffset = node._isAdoptedIn ? 18 : 0;
  652. const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
  653. html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + yOffset + i * 18}" text-anchor="middle">${char}</text>`;
  654. });
  655. // 人名上方勾选框(用于快速选择起始人员)
  656. if (showChartSelectors && node.id !== undefined && node.id !== null) {
  657. const checkedCls = isSelectedRoot ? 'checked' : '';
  658. html += `
  659. <g class="node-selector" data-member-id="${node.id}" transform="translate(${nx - 6}, ${ny - 14})">
  660. <rect class="node-selector-box ${checkedCls}" width="12" height="12"></rect>
  661. ${isSelectedRoot ? '<text class="node-selector-mark" x="6" y="9" text-anchor="middle">✓</text>' : ''}
  662. </g>
  663. `;
  664. }
  665. // 配偶信息已移除,不在传统家谱树中展示
  666. // 标记下一页
  667. if (node.hasNextPage) {
  668. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 25}" text-anchor="middle">下接</text>`;
  669. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 40}" text-anchor="middle">第</text>`;
  670. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 55}" text-anchor="middle">${node.nextPageLink}</text>`;
  671. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 70}" text-anchor="middle">页</text>`;
  672. }
  673. return html;
  674. }).join('');
  675. // 4. 构建左侧世系世代标记
  676. let genLabels = '';
  677. const hasLineageRef = !!page.lineageRef;
  678. const lineageColCount = hasLineageRef ? page.lineageRef.lineage.length : 0;
  679. for(let i=0; i<=maxDepth; i++) {
  680. const actualGen = page.startGen + i;
  681. const y = i * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + 15;
  682. if (hasLineageRef) {
  683. const lineageItems = computeLineageForGen(page.lineageRef.lineage, page.lineageRef.gen, actualGen);
  684. lineageItems.forEach((item, colIdx) => {
  685. const x = 20 + colIdx * 22;
  686. const chars = Array.from(item.formatted);
  687. chars.forEach((ch, ci) => {
  688. genLabels += `<text class="gen-text" x="${x}" y="${y + ci * 15}" text-anchor="middle" style="font-size:13px;">${ch}</text>`;
  689. });
  690. });
  691. } else {
  692. genLabels += `<text class="gen-text" x="30" y="${y}" text-anchor="middle">${actualGen}</text>`;
  693. genLabels += `<text class="gen-text" x="30" y="${y + 18}" text-anchor="middle">世</text>`;
  694. }
  695. genLabels += `<line x1="10" y1="${y-20}" x2="${svgWidth}" y2="${y-20}" stroke="#eee" stroke-dasharray="4" />`;
  696. }
  697. return `
  698. <div class="page-block">
  699. <div class="page-header">
  700. <div class="page-left-title">${page.leftTitle}</div>
  701. <div class="page-title">传统家谱吊线图</div>
  702. <div class="page-subtitle">共 ${totalPages} 页 第 ${pageNum} 页</div>
  703. </div>
  704. <svg class="tree-svg" width="${svgWidth}" height="${svgHeight}">
  705. <!-- 背景和网格 -->
  706. <rect width="100%" height="100%" fill="white" />
  707. ${genLabels}
  708. <!-- 连线 -->
  709. ${lines.join('\n')}
  710. <!-- 节点 -->
  711. ${nodesHtml}
  712. </svg>
  713. </div>`;
  714. }
  715. function applySelectionStateToChart(memberId) {
  716. const selected = selectedRootIdsSet.has(memberId);
  717. document.querySelectorAll(`.node-text[data-member-id="${memberId}"]`).forEach(t => {
  718. t.classList.toggle('selected-root-text', selected);
  719. });
  720. document.querySelectorAll(`.node-selector[data-member-id="${memberId}"]`).forEach(g => {
  721. const rect = g.querySelector('.node-selector-box');
  722. if (rect) rect.classList.toggle('checked', selected);
  723. const mark = g.querySelector('.node-selector-mark');
  724. if (selected && !mark) {
  725. const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  726. textEl.setAttribute('class', 'node-selector-mark');
  727. textEl.setAttribute('x', '6');
  728. textEl.setAttribute('y', '9');
  729. textEl.setAttribute('text-anchor', 'middle');
  730. textEl.textContent = '✓';
  731. g.appendChild(textEl);
  732. } else if (!selected && mark) {
  733. mark.remove();
  734. }
  735. });
  736. }
  737. function toggleSelection(memberId) {
  738. if (Number.isNaN(memberId)) return;
  739. if (selectedRootIdsSet.has(memberId)) selectedRootIdsSet.delete(memberId);
  740. else selectedRootIdsSet.add(memberId);
  741. applySelectionStateToChart(memberId);
  742. syncCheckboxesFromSelectedSet();
  743. renderSelectedMembersPanel();
  744. }
  745. function bindQuickSelectOnChartNames() {
  746. if (!showChartSelectors) return;
  747. // 勾选框点击:仅切换勾选状态,不重绘、不跳页
  748. const selectors = document.querySelectorAll('.node-selector[data-member-id]');
  749. selectors.forEach(el => {
  750. el.addEventListener('click', function(event) {
  751. event.stopPropagation();
  752. const id = parseInt(this.dataset.memberId);
  753. toggleSelection(id);
  754. });
  755. });
  756. // 人名点击:同样仅切换勾选状态
  757. const names = document.querySelectorAll('.node-text[data-member-id]');
  758. names.forEach(el => {
  759. el.addEventListener('click', function(event) {
  760. event.stopPropagation();
  761. const id = parseInt(this.dataset.memberId);
  762. toggleSelection(id);
  763. });
  764. });
  765. }
  766. </script>
  767. </body>
  768. </html>