tree_classic.html 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  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. relations.forEach(rel => {
  461. const parent = memberMap[rel.parent_mid];
  462. const child = memberMap[rel.child_mid];
  463. if (!parent || !child) return;
  464. if (rel.relation_type === 1 || rel.relation_type === 2) {
  465. parent.children.push(child);
  466. child._hasParent = true;
  467. } else if (rel.relation_type === 10) {
  468. parent.spouses.push(child);
  469. child._isSpouse = true;
  470. }
  471. });
  472. // 寻找根节点并推断代数
  473. let roots = [];
  474. if (selectedRootIds && selectedRootIds.length > 0) {
  475. roots = selectedRootIds.map(id => memberMap[id]).filter(m => m);
  476. } else {
  477. roots = clonedMembers.filter(m => !m._hasParent && !m._isSpouse);
  478. }
  479. // 递归填充缺失的代数
  480. function fillGen(node, currentGen) {
  481. if (node.gen === null || isNaN(node.gen)) {
  482. node.gen = currentGen;
  483. }
  484. node.children.forEach(c => fillGen(c, node.gen + 1));
  485. }
  486. // 如果根节点也没有代数,默认给个1
  487. roots.forEach(r => fillGen(r, r.gen || 1));
  488. // 按照世代对roots进行排序,优先显示最老的祖先
  489. roots.sort((a, b) => a.gen - b.gen);
  490. // 查找世系世代参考点:找到任意一个有 name_word_generation 的人
  491. let lineageRef = null;
  492. function findLineageRef(node) {
  493. if (lineageRef) return;
  494. if (node.name_word_generation) {
  495. const parsed = parseLineageGenerations(node.name_word_generation);
  496. if (parsed.length > 0 && !isNaN(parsed[0].num)) {
  497. lineageRef = { gen: node.gen, lineage: parsed };
  498. }
  499. }
  500. if (!lineageRef && node.children) {
  501. node.children.forEach(c => findLineageRef(c));
  502. }
  503. }
  504. roots.forEach(r => findLineageRef(r));
  505. document.getElementById('loading').style.display = 'none';
  506. const container = document.getElementById('exportArea');
  507. if (roots.length === 0) {
  508. container.innerHTML = '<div class="text-muted">暂无家谱数据。</div>';
  509. return;
  510. }
  511. // 2. 分页切块逻辑
  512. const pageQueue = [];
  513. // 将第一批 roots 放入第一页
  514. if (roots.length > 0) {
  515. let minGen = Math.min(...roots.map(r => r.gen));
  516. pageQueue.push({
  517. nodes: roots,
  518. startGen: minGen,
  519. leftTitle: '',
  520. pageId: 1
  521. });
  522. }
  523. let pagesRendered = [];
  524. let nextPageId = 2;
  525. while(pageQueue.length > 0) {
  526. let currentJob = pageQueue.shift();
  527. // 克隆当前块的节点树,遇到超过 MAX_GEN_PER_PAGE 的裁剪并生成新任务
  528. let maxGenForThisPage = currentJob.startGen + CONFIG.MAX_GEN_PER_PAGE - 1;
  529. let blockNodes = cloneAndClip(currentJob.nodes, maxGenForThisPage, pageQueue);
  530. pagesRendered.push({
  531. id: currentJob.pageId,
  532. nodes: blockNodes,
  533. startGen: currentJob.startGen,
  534. endGen: maxGenForThisPage,
  535. leftTitle: currentJob.leftTitle,
  536. lineageRef: lineageRef
  537. });
  538. }
  539. totalPagesCount = pagesRendered.length;
  540. // 3. 渲染页面
  541. pagesRendered.forEach((page, index) => {
  542. const pageHtml = renderPageSVG(page, index + 1, totalPagesCount);
  543. container.insertAdjacentHTML('beforeend', pageHtml);
  544. });
  545. bindQuickSelectOnChartNames();
  546. syncCheckboxesFromSelectedSet();
  547. // Function to clip deep branches
  548. function cloneAndClip(nodes, maxGen, queue) {
  549. return nodes.map(n => {
  550. let clone = { ...n };
  551. // 当当前节点已达到本页最大代数,且还有子节点时,截断并送入下一页
  552. if (clone.gen >= maxGen && clone.children && clone.children.length > 0) {
  553. clone.hasNextPage = true;
  554. clone.nextPageLink = nextPageId;
  555. queue.push({
  556. nodes: clone.children,
  557. startGen: clone.gen + 1,
  558. leftTitle: `上接 ${clone.name}`,
  559. pageId: nextPageId
  560. });
  561. nextPageId++;
  562. clone.children = [];
  563. } else if (clone.children && clone.children.length > 0) {
  564. clone.children = cloneAndClip(clone.children, maxGen, queue);
  565. }
  566. return clone;
  567. });
  568. }
  569. }
  570. // SVG 生成核心逻辑
  571. function renderPageSVG(page, pageNum, totalPages) {
  572. let currentX = 0;
  573. const nodesFlat = [];
  574. const lines = [];
  575. // 1. 布局计算 (核心算法: 父亲对齐长子,从右向左排版 RTL)
  576. function layout(node, depthY) {
  577. if (!node.children || node.children.length === 0) {
  578. node.x = currentX;
  579. node.y = depthY;
  580. currentX -= CONFIG.X_STEP; // 向左递减,实现从右到左排版
  581. } else {
  582. node.children.forEach(child => layout(child, depthY + 1));
  583. node.x = node.children[0].x; // 父亲与长子对齐
  584. node.y = depthY;
  585. }
  586. nodesFlat.push(node);
  587. }
  588. // 对 page.nodes 进行布局
  589. page.nodes.forEach(root => layout(root, 0));
  590. // 获取所需的实际尺寸
  591. const minX = currentX; // 因为向左排布,最小X是负数
  592. const maxDepth = Math.max(...nodesFlat.map(n => n.y), 0);
  593. // 坐标平移,把所有负数X转为正数,并在右侧留白
  594. const offsetX = Math.abs(minX) + CONFIG.MARGIN_LEFT + 50;
  595. const svgWidth = offsetX + 100; // 总宽度
  596. const svgHeight = (maxDepth + 1) * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.MARGIN_BOTTOM;
  597. // 2. 构建连线
  598. nodesFlat.forEach(node => {
  599. if (node.children && node.children.length > 0) {
  600. const firstChild = node.children[0];
  601. const lastChild = node.children[node.children.length - 1];
  602. // 从父亲到底部横线
  603. const pX = node.x + offsetX;
  604. const pY = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.NODE_HEIGHT;
  605. const midY = pY + CONFIG.LINE_MID_OFFSET;
  606. lines.push(`<line class="line" x1="${pX}" y1="${pY}" x2="${pX}" y2="${midY}" />`);
  607. // 绘制子节点水平连线
  608. const firstX = firstChild.x + offsetX;
  609. const lastX = lastChild.x + offsetX;
  610. if (firstX !== lastX) {
  611. lines.push(`<line class="line" x1="${firstX}" y1="${midY}" x2="${lastX}" y2="${midY}" />`);
  612. }
  613. // 绘制连接到每个子节点的垂线
  614. node.children.forEach((child, i) => {
  615. const cX = child.x + offsetX;
  616. const cY = child.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  617. lines.push(`<line class="line" x1="${cX}" y1="${midY}" x2="${cX}" y2="${cY}" />`);
  618. // 右侧标记“长子”、“次子” (因为从右到左,字应该写在左边或者右边)
  619. // 传统家谱写在线右侧
  620. const relLabels = ['长子', '次子', '三子', '四子', '五子', '六子', '七子', '八子'];
  621. let relLabel = relLabels[i] || '子';
  622. if(child.sex === 2) relLabel = '女';
  623. // 竖排显示长子、次子
  624. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 15}">${relLabel[0]}</text>`);
  625. if(relLabel[1]) {
  626. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 28}">${relLabel[1]}</text>`);
  627. }
  628. });
  629. }
  630. });
  631. // 3. 构建节点文字
  632. const nodesHtml = nodesFlat.map(node => {
  633. const nx = node.x + offsetX;
  634. const ny = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  635. const isSelectedRoot = selectedRootIdsSet.has(node.id);
  636. let html = '';
  637. // 姓名竖排处理 (简单切分文字为数组)
  638. let nameArr = Array.from(node.name || '未知');
  639. nameArr.forEach((char, i) => {
  640. const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
  641. html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + i * 18}" text-anchor="middle">${char}</text>`;
  642. });
  643. // 人名上方勾选框(用于快速选择起始人员)
  644. if (showChartSelectors && node.id !== undefined && node.id !== null) {
  645. const checkedCls = isSelectedRoot ? 'checked' : '';
  646. html += `
  647. <g class="node-selector" data-member-id="${node.id}" transform="translate(${nx - 6}, ${ny - 14})">
  648. <rect class="node-selector-box ${checkedCls}" width="12" height="12"></rect>
  649. ${isSelectedRoot ? '<text class="node-selector-mark" x="6" y="9" text-anchor="middle">✓</text>' : ''}
  650. </g>
  651. `;
  652. }
  653. // 配偶信息(放在名字左侧)
  654. if (node.spouses && node.spouses.length > 0) {
  655. const spNames = node.spouses.map(s => s.name).join('、');
  656. let spStr = '配' + spNames;
  657. Array.from(spStr).forEach((char, i) => {
  658. // 左侧(X减小)
  659. html += `<text class="node-spouse" x="${nx - 18}" y="${ny + 12 + i * 14}" text-anchor="middle">${char}</text>`;
  660. });
  661. }
  662. // 标记下一页
  663. if (node.hasNextPage) {
  664. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 25}" text-anchor="middle">下接</text>`;
  665. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 40}" text-anchor="middle">第</text>`;
  666. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 55}" text-anchor="middle">${node.nextPageLink}</text>`;
  667. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 70}" text-anchor="middle">页</text>`;
  668. }
  669. return html;
  670. }).join('');
  671. // 4. 构建左侧世系世代标记
  672. let genLabels = '';
  673. const hasLineageRef = !!page.lineageRef;
  674. const lineageColCount = hasLineageRef ? page.lineageRef.lineage.length : 0;
  675. for(let i=0; i<=maxDepth; i++) {
  676. const actualGen = page.startGen + i;
  677. const y = i * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + 15;
  678. if (hasLineageRef) {
  679. const lineageItems = computeLineageForGen(page.lineageRef.lineage, page.lineageRef.gen, actualGen);
  680. lineageItems.forEach((item, colIdx) => {
  681. const x = 20 + colIdx * 22;
  682. const chars = Array.from(item.formatted);
  683. chars.forEach((ch, ci) => {
  684. genLabels += `<text class="gen-text" x="${x}" y="${y + ci * 15}" text-anchor="middle" style="font-size:13px;">${ch}</text>`;
  685. });
  686. });
  687. } else {
  688. genLabels += `<text class="gen-text" x="30" y="${y}" text-anchor="middle">${actualGen}</text>`;
  689. genLabels += `<text class="gen-text" x="30" y="${y + 18}" text-anchor="middle">世</text>`;
  690. }
  691. genLabels += `<line x1="10" y1="${y-20}" x2="${svgWidth}" y2="${y-20}" stroke="#eee" stroke-dasharray="4" />`;
  692. }
  693. return `
  694. <div class="page-block">
  695. <div class="page-header">
  696. <div class="page-left-title">${page.leftTitle}</div>
  697. <div class="page-title">传统家谱吊线图</div>
  698. <div class="page-subtitle">共 ${totalPages} 页 第 ${pageNum} 页</div>
  699. </div>
  700. <svg class="tree-svg" width="${svgWidth}" height="${svgHeight}">
  701. <!-- 背景和网格 -->
  702. <rect width="100%" height="100%" fill="white" />
  703. ${genLabels}
  704. <!-- 连线 -->
  705. ${lines.join('\n')}
  706. <!-- 节点 -->
  707. ${nodesHtml}
  708. </svg>
  709. </div>`;
  710. }
  711. function applySelectionStateToChart(memberId) {
  712. const selected = selectedRootIdsSet.has(memberId);
  713. document.querySelectorAll(`.node-text[data-member-id="${memberId}"]`).forEach(t => {
  714. t.classList.toggle('selected-root-text', selected);
  715. });
  716. document.querySelectorAll(`.node-selector[data-member-id="${memberId}"]`).forEach(g => {
  717. const rect = g.querySelector('.node-selector-box');
  718. if (rect) rect.classList.toggle('checked', selected);
  719. const mark = g.querySelector('.node-selector-mark');
  720. if (selected && !mark) {
  721. const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  722. textEl.setAttribute('class', 'node-selector-mark');
  723. textEl.setAttribute('x', '6');
  724. textEl.setAttribute('y', '9');
  725. textEl.setAttribute('text-anchor', 'middle');
  726. textEl.textContent = '✓';
  727. g.appendChild(textEl);
  728. } else if (!selected && mark) {
  729. mark.remove();
  730. }
  731. });
  732. }
  733. function toggleSelection(memberId) {
  734. if (Number.isNaN(memberId)) return;
  735. if (selectedRootIdsSet.has(memberId)) selectedRootIdsSet.delete(memberId);
  736. else selectedRootIdsSet.add(memberId);
  737. applySelectionStateToChart(memberId);
  738. syncCheckboxesFromSelectedSet();
  739. renderSelectedMembersPanel();
  740. }
  741. function bindQuickSelectOnChartNames() {
  742. if (!showChartSelectors) return;
  743. // 勾选框点击:仅切换勾选状态,不重绘、不跳页
  744. const selectors = document.querySelectorAll('.node-selector[data-member-id]');
  745. selectors.forEach(el => {
  746. el.addEventListener('click', function(event) {
  747. event.stopPropagation();
  748. const id = parseInt(this.dataset.memberId);
  749. toggleSelection(id);
  750. });
  751. });
  752. // 人名点击:同样仅切换勾选状态
  753. const names = document.querySelectorAll('.node-text[data-member-id]');
  754. names.forEach(el => {
  755. el.addEventListener('click', function(event) {
  756. event.stopPropagation();
  757. const id = parseInt(this.dataset.memberId);
  758. toggleSelection(id);
  759. });
  760. });
  761. }
  762. </script>
  763. </body>
  764. </html>