tree_classic.html 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  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: 60, // 左侧世代标记留白
  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. function buildAndRenderPages(members, relations, selectedRootIds) {
  373. // 1. 构建树结构
  374. const memberMap = {};
  375. // Deep clone members to avoid state pollution between multiple drawings
  376. const clonedMembers = members.map(m => ({...m, children: [], spouses: []}));
  377. clonedMembers.forEach(m => {
  378. m.gen = extractGen(m.family_rank);
  379. memberMap[m.id] = m;
  380. });
  381. relations.forEach(rel => {
  382. const parent = memberMap[rel.parent_mid];
  383. const child = memberMap[rel.child_mid];
  384. if (!parent || !child) return;
  385. if (rel.relation_type === 1 || rel.relation_type === 2) {
  386. parent.children.push(child);
  387. child._hasParent = true;
  388. } else if (rel.relation_type === 10) {
  389. parent.spouses.push(child);
  390. child._isSpouse = true;
  391. }
  392. });
  393. // 寻找根节点并推断代数
  394. let roots = [];
  395. if (selectedRootIds && selectedRootIds.length > 0) {
  396. roots = selectedRootIds.map(id => memberMap[id]).filter(m => m);
  397. } else {
  398. roots = clonedMembers.filter(m => !m._hasParent && !m._isSpouse);
  399. }
  400. // 递归填充缺失的代数
  401. function fillGen(node, currentGen) {
  402. if (node.gen === null || isNaN(node.gen)) {
  403. node.gen = currentGen;
  404. }
  405. node.children.forEach(c => fillGen(c, node.gen + 1));
  406. }
  407. // 如果根节点也没有代数,默认给个1
  408. roots.forEach(r => fillGen(r, r.gen || 1));
  409. // 按照世代对roots进行排序,优先显示最老的祖先
  410. roots.sort((a, b) => a.gen - b.gen);
  411. document.getElementById('loading').style.display = 'none';
  412. const container = document.getElementById('exportArea');
  413. if (roots.length === 0) {
  414. container.innerHTML = '<div class="text-muted">暂无家谱数据。</div>';
  415. return;
  416. }
  417. // 2. 分页切块逻辑
  418. const pageQueue = [];
  419. // 将第一批 roots 放入第一页
  420. if (roots.length > 0) {
  421. let minGen = Math.min(...roots.map(r => r.gen));
  422. pageQueue.push({
  423. nodes: roots,
  424. startGen: minGen,
  425. leftTitle: '',
  426. pageId: 1
  427. });
  428. }
  429. let pagesRendered = [];
  430. let nextPageId = 2;
  431. while(pageQueue.length > 0) {
  432. let currentJob = pageQueue.shift();
  433. // 克隆当前块的节点树,遇到超过 MAX_GEN_PER_PAGE 的裁剪并生成新任务
  434. let maxGenForThisPage = currentJob.startGen + CONFIG.MAX_GEN_PER_PAGE - 1;
  435. let blockNodes = cloneAndClip(currentJob.nodes, maxGenForThisPage, pageQueue);
  436. pagesRendered.push({
  437. id: currentJob.pageId,
  438. nodes: blockNodes,
  439. startGen: currentJob.startGen,
  440. endGen: maxGenForThisPage,
  441. leftTitle: currentJob.leftTitle
  442. });
  443. }
  444. totalPagesCount = pagesRendered.length;
  445. // 3. 渲染页面
  446. pagesRendered.forEach((page, index) => {
  447. const pageHtml = renderPageSVG(page, index + 1, totalPagesCount);
  448. container.insertAdjacentHTML('beforeend', pageHtml);
  449. });
  450. bindQuickSelectOnChartNames();
  451. syncCheckboxesFromSelectedSet();
  452. // Function to clip deep branches
  453. function cloneAndClip(nodes, maxGen, queue) {
  454. return nodes.map(n => {
  455. let clone = { ...n };
  456. // 当当前节点已达到本页最大代数,且还有子节点时,截断并送入下一页
  457. if (clone.gen >= maxGen && clone.children && clone.children.length > 0) {
  458. clone.hasNextPage = true;
  459. clone.nextPageLink = nextPageId;
  460. queue.push({
  461. nodes: clone.children,
  462. startGen: clone.gen + 1,
  463. leftTitle: `上接 ${clone.name}`,
  464. pageId: nextPageId
  465. });
  466. nextPageId++;
  467. clone.children = [];
  468. } else if (clone.children && clone.children.length > 0) {
  469. clone.children = cloneAndClip(clone.children, maxGen, queue);
  470. }
  471. return clone;
  472. });
  473. }
  474. }
  475. // SVG 生成核心逻辑
  476. function renderPageSVG(page, pageNum, totalPages) {
  477. let currentX = 0;
  478. const nodesFlat = [];
  479. const lines = [];
  480. // 1. 布局计算 (核心算法: 父亲对齐长子,从右向左排版 RTL)
  481. function layout(node, depthY) {
  482. if (!node.children || node.children.length === 0) {
  483. node.x = currentX;
  484. node.y = depthY;
  485. currentX -= CONFIG.X_STEP; // 向左递减,实现从右到左排版
  486. } else {
  487. node.children.forEach(child => layout(child, depthY + 1));
  488. node.x = node.children[0].x; // 父亲与长子对齐
  489. node.y = depthY;
  490. }
  491. nodesFlat.push(node);
  492. }
  493. // 对 page.nodes 进行布局
  494. page.nodes.forEach(root => layout(root, 0));
  495. // 获取所需的实际尺寸
  496. const minX = currentX; // 因为向左排布,最小X是负数
  497. const maxDepth = Math.max(...nodesFlat.map(n => n.y), 0);
  498. // 坐标平移,把所有负数X转为正数,并在右侧留白
  499. const offsetX = Math.abs(minX) + CONFIG.MARGIN_LEFT + 50;
  500. const svgWidth = offsetX + 100; // 总宽度
  501. const svgHeight = (maxDepth + 1) * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.MARGIN_BOTTOM;
  502. // 2. 构建连线
  503. nodesFlat.forEach(node => {
  504. if (node.children && node.children.length > 0) {
  505. const firstChild = node.children[0];
  506. const lastChild = node.children[node.children.length - 1];
  507. // 从父亲到底部横线
  508. const pX = node.x + offsetX;
  509. const pY = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.NODE_HEIGHT;
  510. const midY = pY + CONFIG.LINE_MID_OFFSET;
  511. lines.push(`<line class="line" x1="${pX}" y1="${pY}" x2="${pX}" y2="${midY}" />`);
  512. // 绘制子节点水平连线
  513. const firstX = firstChild.x + offsetX;
  514. const lastX = lastChild.x + offsetX;
  515. if (firstX !== lastX) {
  516. lines.push(`<line class="line" x1="${firstX}" y1="${midY}" x2="${lastX}" y2="${midY}" />`);
  517. }
  518. // 绘制连接到每个子节点的垂线
  519. node.children.forEach((child, i) => {
  520. const cX = child.x + offsetX;
  521. const cY = child.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  522. lines.push(`<line class="line" x1="${cX}" y1="${midY}" x2="${cX}" y2="${cY}" />`);
  523. // 右侧标记“长子”、“次子” (因为从右到左,字应该写在左边或者右边)
  524. // 传统家谱写在线右侧
  525. const relLabels = ['长子', '次子', '三子', '四子', '五子', '六子', '七子', '八子'];
  526. let relLabel = relLabels[i] || '子';
  527. if(child.sex === 2) relLabel = '女';
  528. // 竖排显示长子、次子
  529. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 15}">${relLabel[0]}</text>`);
  530. if(relLabel[1]) {
  531. lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 28}">${relLabel[1]}</text>`);
  532. }
  533. });
  534. }
  535. });
  536. // 3. 构建节点文字
  537. const nodesHtml = nodesFlat.map(node => {
  538. const nx = node.x + offsetX;
  539. const ny = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
  540. const isSelectedRoot = selectedRootIdsSet.has(node.id);
  541. let html = '';
  542. // 姓名竖排处理 (简单切分文字为数组)
  543. let nameArr = Array.from(node.name || '未知');
  544. nameArr.forEach((char, i) => {
  545. const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
  546. html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + i * 18}" text-anchor="middle">${char}</text>`;
  547. });
  548. // 人名上方勾选框(用于快速选择起始人员)
  549. if (showChartSelectors && node.id !== undefined && node.id !== null) {
  550. const checkedCls = isSelectedRoot ? 'checked' : '';
  551. html += `
  552. <g class="node-selector" data-member-id="${node.id}" transform="translate(${nx - 6}, ${ny - 14})">
  553. <rect class="node-selector-box ${checkedCls}" width="12" height="12"></rect>
  554. ${isSelectedRoot ? '<text class="node-selector-mark" x="6" y="9" text-anchor="middle">✓</text>' : ''}
  555. </g>
  556. `;
  557. }
  558. // 配偶信息(放在名字左侧)
  559. if (node.spouses && node.spouses.length > 0) {
  560. const spNames = node.spouses.map(s => s.name).join('、');
  561. let spStr = '配' + spNames;
  562. Array.from(spStr).forEach((char, i) => {
  563. // 左侧(X减小)
  564. html += `<text class="node-spouse" x="${nx - 18}" y="${ny + 12 + i * 14}" text-anchor="middle">${char}</text>`;
  565. });
  566. }
  567. // 标记下一页
  568. if (node.hasNextPage) {
  569. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 25}" text-anchor="middle">下接</text>`;
  570. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 40}" text-anchor="middle">第</text>`;
  571. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 55}" text-anchor="middle">${node.nextPageLink}</text>`;
  572. html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 70}" text-anchor="middle">页</text>`;
  573. }
  574. return html;
  575. }).join('');
  576. // 4. 构建左侧世代标记
  577. let genLabels = '';
  578. for(let i=0; i<=maxDepth; i++) {
  579. const actualGen = page.startGen + i;
  580. const y = i * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + 15;
  581. const x = 30; // 左侧留白
  582. // 将代数竖排
  583. let genStr = actualGen + '世';
  584. // 支持如"30世"
  585. genLabels += `<text class="gen-text" x="${x}" y="${y}" text-anchor="middle">${actualGen}</text>`;
  586. genLabels += `<text class="gen-text" x="${x}" y="${y + 18}" text-anchor="middle">世</text>`;
  587. // 画一条灰色的分隔虚线(非必须,但好看)
  588. genLabels += `<line x1="10" y1="${y-20}" x2="${svgWidth}" y2="${y-20}" stroke="#eee" stroke-dasharray="4" />`;
  589. }
  590. return `
  591. <div class="page-block">
  592. <div class="page-header">
  593. <div class="page-left-title">${page.leftTitle}</div>
  594. <div class="page-title">传统家谱吊线图</div>
  595. <div class="page-subtitle">共 ${totalPages} 页 第 ${pageNum} 页</div>
  596. </div>
  597. <svg class="tree-svg" width="${svgWidth}" height="${svgHeight}">
  598. <!-- 背景和网格 -->
  599. <rect width="100%" height="100%" fill="white" />
  600. ${genLabels}
  601. <!-- 连线 -->
  602. ${lines.join('\n')}
  603. <!-- 节点 -->
  604. ${nodesHtml}
  605. </svg>
  606. </div>`;
  607. }
  608. function applySelectionStateToChart(memberId) {
  609. const selected = selectedRootIdsSet.has(memberId);
  610. document.querySelectorAll(`.node-text[data-member-id="${memberId}"]`).forEach(t => {
  611. t.classList.toggle('selected-root-text', selected);
  612. });
  613. document.querySelectorAll(`.node-selector[data-member-id="${memberId}"]`).forEach(g => {
  614. const rect = g.querySelector('.node-selector-box');
  615. if (rect) rect.classList.toggle('checked', selected);
  616. const mark = g.querySelector('.node-selector-mark');
  617. if (selected && !mark) {
  618. const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  619. textEl.setAttribute('class', 'node-selector-mark');
  620. textEl.setAttribute('x', '6');
  621. textEl.setAttribute('y', '9');
  622. textEl.setAttribute('text-anchor', 'middle');
  623. textEl.textContent = '✓';
  624. g.appendChild(textEl);
  625. } else if (!selected && mark) {
  626. mark.remove();
  627. }
  628. });
  629. }
  630. function toggleSelection(memberId) {
  631. if (Number.isNaN(memberId)) return;
  632. if (selectedRootIdsSet.has(memberId)) selectedRootIdsSet.delete(memberId);
  633. else selectedRootIdsSet.add(memberId);
  634. applySelectionStateToChart(memberId);
  635. syncCheckboxesFromSelectedSet();
  636. renderSelectedMembersPanel();
  637. }
  638. function bindQuickSelectOnChartNames() {
  639. if (!showChartSelectors) return;
  640. // 勾选框点击:仅切换勾选状态,不重绘、不跳页
  641. const selectors = document.querySelectorAll('.node-selector[data-member-id]');
  642. selectors.forEach(el => {
  643. el.addEventListener('click', function(event) {
  644. event.stopPropagation();
  645. const id = parseInt(this.dataset.memberId);
  646. toggleSelection(id);
  647. });
  648. });
  649. // 人名点击:同样仅切换勾选状态
  650. const names = document.querySelectorAll('.node-text[data-member-id]');
  651. names.forEach(el => {
  652. el.addEventListener('click', function(event) {
  653. event.stopPropagation();
  654. const id = parseInt(this.dataset.memberId);
  655. toggleSelection(id);
  656. });
  657. });
  658. }
  659. </script>
  660. </body>
  661. </html>