tree_classic.html 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848
  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. document.addEventListener('DOMContentLoaded', function() {
  227. loadTreeData();
  228. // Search filter for member list
  229. document.getElementById('memberSearch').addEventListener('input', function() {
  230. const term = this.value.trim().toLowerCase();
  231. const labels = document.querySelectorAll('#memberList label');
  232. labels.forEach(lbl => {
  233. const text = lbl.textContent.toLowerCase();
  234. if (text.includes(term)) {
  235. lbl.style.display = 'block';
  236. } else {
  237. lbl.style.display = 'none';
  238. }
  239. });
  240. });
  241. document.getElementById('memberSelectModal').addEventListener('show.bs.modal', function() {
  242. syncCheckboxesFromSelectedSet();
  243. renderSelectedMembersPanel();
  244. });
  245. document.getElementById('btnExport').addEventListener('click', function() {
  246. const element = document.getElementById('exportArea');
  247. const btn = this;
  248. const originalHtml = btn.innerHTML;
  249. btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在渲染...';
  250. btn.disabled = true;
  251. setTimeout(() => {
  252. html2canvas(element, {
  253. scale: 2,
  254. backgroundColor: '#f0f2f5',
  255. logging: false
  256. }).then(canvas => {
  257. const imgData = canvas.toDataURL('image/jpeg', 0.9);
  258. const link = document.createElement('a');
  259. link.download = '家谱吊线图_' + new Date().getTime() + '.jpg';
  260. link.href = imgData;
  261. link.click();
  262. btn.innerHTML = originalHtml;
  263. btn.disabled = false;
  264. }).catch(err => {
  265. console.error('导出失败:', err);
  266. alert('导出失败,请重试。');
  267. btn.innerHTML = originalHtml;
  268. btn.disabled = false;
  269. });
  270. }, 500);
  271. });
  272. });
  273. function selectAllMembers(checked) {
  274. document.querySelectorAll('.member-checkbox').forEach(cb => {
  275. if (cb.parentElement.style.display !== 'none') {
  276. cb.checked = checked;
  277. }
  278. });
  279. syncSelectedSetFromCheckboxes();
  280. renderSelectedMembersPanel();
  281. }
  282. function syncSelectedSetFromCheckboxes() {
  283. selectedRootIdsSet = new Set(
  284. Array.from(document.querySelectorAll('.member-checkbox:checked'))
  285. .map(cb => parseInt(cb.value))
  286. .filter(v => !Number.isNaN(v))
  287. );
  288. }
  289. function syncCheckboxesFromSelectedSet() {
  290. document.querySelectorAll('.member-checkbox').forEach(cb => {
  291. const id = parseInt(cb.value);
  292. cb.checked = selectedRootIdsSet.has(id);
  293. });
  294. }
  295. function renderSelectedMembersPanel() {
  296. const panel = document.getElementById('selectedMembersPanel');
  297. if (!panel) return;
  298. const selectedMembers = allMembersData.filter(m => selectedRootIdsSet.has(m.id));
  299. if (selectedMembers.length === 0) {
  300. panel.innerHTML = '<span class="text-muted small">当前未勾选任何人员</span>';
  301. return;
  302. }
  303. panel.innerHTML = selectedMembers.map(m => `
  304. <span class="selected-member-chip">
  305. <span>${m.name || ('ID:' + m.id)}</span>
  306. <button type="button" data-remove-id="${m.id}" title="移除">×</button>
  307. </span>
  308. `).join('');
  309. panel.querySelectorAll('button[data-remove-id]').forEach(btn => {
  310. btn.addEventListener('click', function() {
  311. const id = parseInt(this.dataset.removeId);
  312. if (Number.isNaN(id)) return;
  313. selectedRootIdsSet.delete(id);
  314. syncCheckboxesFromSelectedSet();
  315. renderSelectedMembersPanel();
  316. });
  317. });
  318. }
  319. function drawSelectedTree() {
  320. const selectedIds = Array.from(document.querySelectorAll('.member-checkbox:checked')).map(cb => parseInt(cb.value));
  321. if (selectedIds.length === 0) {
  322. alert('请至少选择一位起始人员。');
  323. return;
  324. }
  325. selectedRootIdsSet = new Set(selectedIds);
  326. showChartSelectors = false; // 点击“开始绘制”后进入正式输出模式,不显示勾选框
  327. // Close modal
  328. const modalEl = document.getElementById('memberSelectModal');
  329. const modal = bootstrap.Modal.getInstance(modalEl);
  330. if(modal) modal.hide();
  331. document.getElementById('loading').style.display = 'block';
  332. document.getElementById('exportArea').innerHTML = '';
  333. // Allow UI to update before heavy computation
  334. setTimeout(() => {
  335. buildAndRenderPages(allMembersData, allRelationsData, selectedIds);
  336. }, 100);
  337. }
  338. async function loadTreeData() {
  339. try {
  340. const response = await fetch('/manager/api/tree_data');
  341. const data = await response.json();
  342. if (data.error) return alert('获取数据失败: ' + data.error);
  343. allMembersData = data.members;
  344. allRelationsData = data.relations;
  345. // Populate member list in modal
  346. const listHtml = allMembersData.map(m => {
  347. const gen = m.family_rank ? ` (排行/代数: ${m.family_rank})` : '';
  348. return `
  349. <label class="list-group-item">
  350. <input class="form-check-input me-1 member-checkbox" type="checkbox" value="${m.id}">
  351. ${m.name}${gen}
  352. </label>
  353. `;
  354. }).join('');
  355. document.getElementById('memberList').innerHTML = listHtml;
  356. document.querySelectorAll('.member-checkbox').forEach(cb => {
  357. cb.addEventListener('change', syncSelectedSetFromCheckboxes);
  358. cb.addEventListener('change', renderSelectedMembersPanel);
  359. });
  360. // default build using empty selection (will find absolute roots)
  361. buildAndRenderPages(data.members, data.relations, []);
  362. } catch (error) {
  363. console.error(error);
  364. document.getElementById('loading').innerHTML = '<span class="text-danger">加载失败,请检查网络并重试。</span>';
  365. }
  366. }
  367. function extractGen(rankStr) {
  368. if (!rankStr) return null;
  369. const match = String(rankStr).match(/\d+/);
  370. return match ? parseInt(match[0]) : null;
  371. }
  372. // ===== 世系世代:中文数字转换 =====
  373. const CN_DIGITS = ['零','一','二','三','四','五','六','七','八','九'];
  374. const CN_UNITS = ['','十','百','千','万'];
  375. function numToChinese(n) {
  376. if (n <= 0) return '零';
  377. if (n <= 10) return n === 10 ? '十' : CN_DIGITS[n];
  378. if (n < 20) return '十' + (n % 10 === 0 ? '' : CN_DIGITS[n % 10]);
  379. let result = '';
  380. const str = String(n);
  381. const len = str.length;
  382. for (let i = 0; i < len; i++) {
  383. const d = parseInt(str[i]);
  384. const unitIdx = len - 1 - i;
  385. if (d === 0) {
  386. if (result && !result.endsWith('零') && i < len - 1) result += '零';
  387. } else {
  388. result += CN_DIGITS[d] + CN_UNITS[unitIdx];
  389. }
  390. }
  391. return result.replace(/零+$/, '');
  392. }
  393. function chineseToNum(s) {
  394. if (!s) return NaN;
  395. s = s.trim();
  396. const digitMap = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
  397. const unitMap = {'十':10,'百':100,'千':1000,'万':10000};
  398. let result = 0, temp = 0;
  399. for (let i = 0; i < s.length; i++) {
  400. const ch = s[i];
  401. if (digitMap[ch] !== undefined) {
  402. temp = digitMap[ch];
  403. } else if (unitMap[ch] !== undefined) {
  404. if (temp === 0 && unitMap[ch] === 10) temp = 1;
  405. result += temp * unitMap[ch];
  406. temp = 0;
  407. }
  408. }
  409. result += temp;
  410. return result || NaN;
  411. }
  412. function parseLineageGenerations(str) {
  413. if (!str) return [];
  414. return str.split(';').filter(s => s.trim()).map(item => {
  415. item = item.trim();
  416. const m = item.match(/^(.+?)第(.+?)代$/);
  417. if (m) {
  418. return { place: m[1], num: chineseToNum(m[2]), raw: item };
  419. }
  420. return { place: '', num: NaN, raw: item };
  421. });
  422. }
  423. function formatLineageGeneration(place, num) {
  424. if (isNaN(num) || num <= 0) return '';
  425. return place + '第' + numToChinese(num) + '代';
  426. }
  427. function computeLineageForGen(refLineage, refGen, targetGen) {
  428. if (!refLineage || refLineage.length === 0) return [];
  429. const diff = targetGen - refGen;
  430. return refLineage.map(lg => ({
  431. place: lg.place,
  432. num: lg.num + diff,
  433. formatted: formatLineageGeneration(lg.place, lg.num + diff)
  434. })).filter(lg => lg.num > 0);
  435. }
  436. function buildAndRenderPages(members, relations, selectedRootIds) {
  437. // 1. 构建树结构
  438. const memberMap = {};
  439. // Deep clone members to avoid state pollution between multiple drawings
  440. const clonedMembers = members.map(m => ({...m, children: [], spouses: []}));
  441. clonedMembers.forEach(m => {
  442. m.gen = extractGen(m.family_rank);
  443. memberMap[m.id] = m;
  444. });
  445. relations.forEach(rel => {
  446. const parent = memberMap[rel.parent_mid];
  447. const child = memberMap[rel.child_mid];
  448. if (!parent || !child) return;
  449. if (rel.relation_type === 1 || rel.relation_type === 2) {
  450. parent.children.push(child);
  451. child._hasParent = true;
  452. } else if (rel.relation_type === 10) {
  453. parent.spouses.push(child);
  454. child._isSpouse = true;
  455. }
  456. });
  457. // 寻找根节点并推断代数
  458. let roots = [];
  459. if (selectedRootIds && selectedRootIds.length > 0) {
  460. roots = selectedRootIds.map(id => memberMap[id]).filter(m => m);
  461. } else {
  462. roots = clonedMembers.filter(m => !m._hasParent && !m._isSpouse);
  463. }
  464. // 递归填充缺失的代数
  465. function fillGen(node, currentGen) {
  466. if (node.gen === null || isNaN(node.gen)) {
  467. node.gen = currentGen;
  468. }
  469. node.children.forEach(c => fillGen(c, node.gen + 1));
  470. }
  471. // 如果根节点也没有代数,默认给个1
  472. roots.forEach(r => fillGen(r, r.gen || 1));
  473. // 按照世代对roots进行排序,优先显示最老的祖先
  474. roots.sort((a, b) => a.gen - b.gen);
  475. // 查找世系世代参考点:找到任意一个有 name_word_generation 的人
  476. let lineageRef = null;
  477. function findLineageRef(node) {
  478. if (lineageRef) return;
  479. if (node.name_word_generation) {
  480. const parsed = parseLineageGenerations(node.name_word_generation);
  481. if (parsed.length > 0 && !isNaN(parsed[0].num)) {
  482. lineageRef = { gen: node.gen, lineage: parsed };
  483. }
  484. }
  485. if (!lineageRef && node.children) {
  486. node.children.forEach(c => findLineageRef(c));
  487. }
  488. }
  489. roots.forEach(r => findLineageRef(r));
  490. document.getElementById('loading').style.display = 'none';
  491. const container = document.getElementById('exportArea');
  492. if (roots.length === 0) {
  493. container.innerHTML = '<div class="text-muted">暂无家谱数据。</div>';
  494. return;
  495. }
  496. // 2. 分页切块逻辑
  497. const pageQueue = [];
  498. // 将第一批 roots 放入第一页
  499. if (roots.length > 0) {
  500. let minGen = Math.min(...roots.map(r => r.gen));
  501. pageQueue.push({
  502. nodes: roots,
  503. startGen: minGen,
  504. leftTitle: '',
  505. pageId: 1
  506. });
  507. }
  508. let pagesRendered = [];
  509. let nextPageId = 2;
  510. while(pageQueue.length > 0) {
  511. let currentJob = pageQueue.shift();
  512. // 克隆当前块的节点树,遇到超过 MAX_GEN_PER_PAGE 的裁剪并生成新任务
  513. let maxGenForThisPage = currentJob.startGen + CONFIG.MAX_GEN_PER_PAGE - 1;
  514. let blockNodes = cloneAndClip(currentJob.nodes, maxGenForThisPage, pageQueue);
  515. pagesRendered.push({
  516. id: currentJob.pageId,
  517. nodes: blockNodes,
  518. startGen: currentJob.startGen,
  519. endGen: maxGenForThisPage,
  520. leftTitle: currentJob.leftTitle,
  521. lineageRef: lineageRef
  522. });
  523. }
  524. totalPagesCount = pagesRendered.length;
  525. // 3. 渲染页面
  526. pagesRendered.forEach((page, index) => {
  527. const pageHtml = renderPageSVG(page, index + 1, totalPagesCount);
  528. container.insertAdjacentHTML('beforeend', pageHtml);
  529. });
  530. bindQuickSelectOnChartNames();
  531. syncCheckboxesFromSelectedSet();
  532. // Function to clip deep branches
  533. function cloneAndClip(nodes, maxGen, queue) {
  534. return nodes.map(n => {
  535. let clone = { ...n };
  536. // 当当前节点已达到本页最大代数,且还有子节点时,截断并送入下一页
  537. if (clone.gen >= maxGen && clone.children && clone.children.length > 0) {
  538. clone.hasNextPage = true;
  539. clone.nextPageLink = nextPageId;
  540. queue.push({
  541. nodes: clone.children,
  542. startGen: clone.gen + 1,
  543. leftTitle: `上接 ${clone.name}`,
  544. pageId: nextPageId
  545. });
  546. nextPageId++;
  547. clone.children = [];
  548. } else if (clone.children && clone.children.length > 0) {
  549. clone.children = cloneAndClip(clone.children, maxGen, queue);
  550. }
  551. return clone;
  552. });
  553. }
  554. }
  555. // SVG 生成核心逻辑
  556. function renderPageSVG(page, pageNum, totalPages) {
  557. let currentX = 0;
  558. const nodesFlat = [];
  559. const lines = [];
  560. // 1. 布局计算 (核心算法: 父亲对齐长子,从右向左排版 RTL)
  561. function layout(node, depthY) {
  562. if (!node.children || node.children.length === 0) {
  563. node.x = currentX;
  564. node.y = depthY;
  565. currentX -= CONFIG.X_STEP; // 向左递减,实现从右到左排版
  566. } else {
  567. node.children.forEach(child => layout(child, depthY + 1));
  568. node.x = node.children[0].x; // 父亲与长子对齐
  569. node.y = depthY;
  570. }
  571. nodesFlat.push(node);
  572. }
  573. // 对 page.nodes 进行布局
  574. page.nodes.forEach(root => layout(root, 0));
  575. // 获取所需的实际尺寸
  576. const minX = currentX; // 因为向左排布,最小X是负数
  577. const maxDepth = Math.max(...nodesFlat.map(n => n.y), 0);
  578. // 坐标平移,把所有负数X转为正数,并在右侧留白
  579. const offsetX = Math.abs(minX) + CONFIG.MARGIN_LEFT + 50;
  580. const svgWidth = offsetX + 100; // 总宽度
  581. const svgHeight = (maxDepth + 1) * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.MARGIN_BOTTOM;
  582. // 2. 构建连线
  583. nodesFlat.forEach(node => {
  584. if (node.children && node.children.length > 0) {
  585. const firstChild = node.children[0];
  586. const lastChild = node.children[node.children.length - 1];
  587. // 从父亲到底部横线
  588. const pX = node.x + offsetX;
  589. const pY = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.NODE_HEIGHT;
  590. const midY = pY + CONFIG.LINE_MID_OFFSET;
  591. lines.push(`<line class="line" x1="${pX}" y1="${pY}" x2="${pX}" y2="${midY}" />`);
  592. // 绘制子节点水平连线
  593. const firstX = firstChild.x + offsetX;
  594. const lastX = lastChild.x + offsetX;
  595. if (firstX !== lastX) {
  596. lines.push(`<line class="line" x1="${firstX}" y1="${midY}" x2="${lastX}" y2="${midY}" />`);
  597. }
  598. // 绘制连接到每个子节点的垂线
  599. node.children.forEach((child, i) => {
  600. const cX = child.x + offsetX;
  601. const cY = child.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  602. lines.push(`<line class="line" x1="${cX}" y1="${midY}" x2="${cX}" y2="${cY}" />`);
  603. // 右侧标记“长子”、“次子” (因为从右到左,字应该写在左边或者右边)
  604. // 传统家谱写在线右侧
  605. const relLabels = ['长子', '次子', '三子', '四子', '五子', '六子', '七子', '八子'];
  606. let relLabel = relLabels[i] || '子';
  607. if(child.sex === 2) relLabel = '女';
  608. // 竖排显示长子、次子
  609. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 15}">${relLabel[0]}</text>`);
  610. if(relLabel[1]) {
  611. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 28}">${relLabel[1]}</text>`);
  612. }
  613. });
  614. }
  615. });
  616. // 3. 构建节点文字
  617. const nodesHtml = nodesFlat.map(node => {
  618. const nx = node.x + offsetX;
  619. const ny = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  620. const isSelectedRoot = selectedRootIdsSet.has(node.id);
  621. let html = '';
  622. // 姓名竖排处理 (简单切分文字为数组)
  623. let nameArr = Array.from(node.name || '未知');
  624. nameArr.forEach((char, i) => {
  625. const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
  626. html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + i * 18}" text-anchor="middle">${char}</text>`;
  627. });
  628. // 人名上方勾选框(用于快速选择起始人员)
  629. if (showChartSelectors && node.id !== undefined && node.id !== null) {
  630. const checkedCls = isSelectedRoot ? 'checked' : '';
  631. html += `
  632. <g class="node-selector" data-member-id="${node.id}" transform="translate(${nx - 6}, ${ny - 14})">
  633. <rect class="node-selector-box ${checkedCls}" width="12" height="12"></rect>
  634. ${isSelectedRoot ? '<text class="node-selector-mark" x="6" y="9" text-anchor="middle">✓</text>' : ''}
  635. </g>
  636. `;
  637. }
  638. // 配偶信息(放在名字左侧)
  639. if (node.spouses && node.spouses.length > 0) {
  640. const spNames = node.spouses.map(s => s.name).join('、');
  641. let spStr = '配' + spNames;
  642. Array.from(spStr).forEach((char, i) => {
  643. // 左侧(X减小)
  644. html += `<text class="node-spouse" x="${nx - 18}" y="${ny + 12 + i * 14}" text-anchor="middle">${char}</text>`;
  645. });
  646. }
  647. // 标记下一页
  648. if (node.hasNextPage) {
  649. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 25}" text-anchor="middle">下接</text>`;
  650. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 40}" text-anchor="middle">第</text>`;
  651. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 55}" text-anchor="middle">${node.nextPageLink}</text>`;
  652. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 70}" text-anchor="middle">页</text>`;
  653. }
  654. return html;
  655. }).join('');
  656. // 4. 构建左侧世系世代标记
  657. let genLabels = '';
  658. const hasLineageRef = !!page.lineageRef;
  659. const lineageColCount = hasLineageRef ? page.lineageRef.lineage.length : 0;
  660. for(let i=0; i<=maxDepth; i++) {
  661. const actualGen = page.startGen + i;
  662. const y = i * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + 15;
  663. if (hasLineageRef) {
  664. const lineageItems = computeLineageForGen(page.lineageRef.lineage, page.lineageRef.gen, actualGen);
  665. lineageItems.forEach((item, colIdx) => {
  666. const x = 20 + colIdx * 22;
  667. const chars = Array.from(item.formatted);
  668. chars.forEach((ch, ci) => {
  669. genLabels += `<text class="gen-text" x="${x}" y="${y + ci * 15}" text-anchor="middle" style="font-size:13px;">${ch}</text>`;
  670. });
  671. });
  672. } else {
  673. genLabels += `<text class="gen-text" x="30" y="${y}" text-anchor="middle">${actualGen}</text>`;
  674. genLabels += `<text class="gen-text" x="30" y="${y + 18}" text-anchor="middle">世</text>`;
  675. }
  676. genLabels += `<line x1="10" y1="${y-20}" x2="${svgWidth}" y2="${y-20}" stroke="#eee" stroke-dasharray="4" />`;
  677. }
  678. return `
  679. <div class="page-block">
  680. <div class="page-header">
  681. <div class="page-left-title">${page.leftTitle}</div>
  682. <div class="page-title">传统家谱吊线图</div>
  683. <div class="page-subtitle">共 ${totalPages} 页 第 ${pageNum} 页</div>
  684. </div>
  685. <svg class="tree-svg" width="${svgWidth}" height="${svgHeight}">
  686. <!-- 背景和网格 -->
  687. <rect width="100%" height="100%" fill="white" />
  688. ${genLabels}
  689. <!-- 连线 -->
  690. ${lines.join('\n')}
  691. <!-- 节点 -->
  692. ${nodesHtml}
  693. </svg>
  694. </div>`;
  695. }
  696. function applySelectionStateToChart(memberId) {
  697. const selected = selectedRootIdsSet.has(memberId);
  698. document.querySelectorAll(`.node-text[data-member-id="${memberId}"]`).forEach(t => {
  699. t.classList.toggle('selected-root-text', selected);
  700. });
  701. document.querySelectorAll(`.node-selector[data-member-id="${memberId}"]`).forEach(g => {
  702. const rect = g.querySelector('.node-selector-box');
  703. if (rect) rect.classList.toggle('checked', selected);
  704. const mark = g.querySelector('.node-selector-mark');
  705. if (selected && !mark) {
  706. const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  707. textEl.setAttribute('class', 'node-selector-mark');
  708. textEl.setAttribute('x', '6');
  709. textEl.setAttribute('y', '9');
  710. textEl.setAttribute('text-anchor', 'middle');
  711. textEl.textContent = '✓';
  712. g.appendChild(textEl);
  713. } else if (!selected && mark) {
  714. mark.remove();
  715. }
  716. });
  717. }
  718. function toggleSelection(memberId) {
  719. if (Number.isNaN(memberId)) return;
  720. if (selectedRootIdsSet.has(memberId)) selectedRootIdsSet.delete(memberId);
  721. else selectedRootIdsSet.add(memberId);
  722. applySelectionStateToChart(memberId);
  723. syncCheckboxesFromSelectedSet();
  724. renderSelectedMembersPanel();
  725. }
  726. function bindQuickSelectOnChartNames() {
  727. if (!showChartSelectors) return;
  728. // 勾选框点击:仅切换勾选状态,不重绘、不跳页
  729. const selectors = document.querySelectorAll('.node-selector[data-member-id]');
  730. selectors.forEach(el => {
  731. el.addEventListener('click', function(event) {
  732. event.stopPropagation();
  733. const id = parseInt(this.dataset.memberId);
  734. toggleSelection(id);
  735. });
  736. });
  737. // 人名点击:同样仅切换勾选状态
  738. const names = document.querySelectorAll('.node-text[data-member-id]');
  739. names.forEach(el => {
  740. el.addEventListener('click', function(event) {
  741. event.stopPropagation();
  742. const id = parseInt(this.dataset.memberId);
  743. toggleSelection(id);
  744. });
  745. });
  746. }
  747. </script>
  748. </body>
  749. </html>