| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <title>传统吊线图导出 - 家谱管理系统</title>
- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
- <style>
- body {
- background-color: #f0f2f5;
- padding: 20px;
- font-family: "Microsoft YaHei", "SimSun", serif;
- }
- .toolbar {
- background: white;
- padding: 15px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- margin-bottom: 20px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
-
- .export-container {
- margin: 0 auto;
- display: flex;
- flex-direction: column;
- gap: 40px;
- align-items: center;
- }
- .page-block {
- background-color: #fff;
- padding: 40px 60px;
- border: 1px solid #ddd;
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
- position: relative;
- /* 类似A4纸横向或纵向,这里允许根据内容自适应宽度 */
- min-width: 1000px;
- overflow-x: auto;
- }
- .page-header {
- text-align: center;
- margin-bottom: 30px;
- position: relative;
- }
-
- .page-title {
- font-size: 28px;
- font-weight: bold;
- letter-spacing: 4px;
- font-family: "KaiTi", "SimSun", serif;
- }
- .page-subtitle {
- position: absolute;
- right: 0;
- top: 10px;
- font-size: 14px;
- color: #333;
- }
-
- .page-left-title {
- position: absolute;
- left: 0;
- top: 10px;
- font-size: 14px;
- color: #333;
- }
- /* SVG 样式 */
- .tree-svg {
- display: block;
- margin: 0 auto;
- overflow: visible;
- }
- .line {
- fill: none;
- stroke: #333;
- stroke-width: 1.5px;
- shape-rendering: crispEdges;
- }
- .gen-text {
- font-size: 16px;
- fill: #d32f2f;
- font-weight: bold;
- font-family: "KaiTi", "SimSun", serif;
- }
- .node-rect {
- fill: #fff;
- }
- .node-text {
- font-size: 18px;
- font-weight: bold;
- fill: #000;
- font-family: "KaiTi", "SimSun", serif;
- }
- .node-text.selected-root-text {
- fill: #0d6efd;
- font-weight: 700;
- }
- .node-selector {
- cursor: pointer;
- }
- .node-selector-box {
- fill: #fff;
- stroke: #999;
- stroke-width: 1.2px;
- rx: 2px;
- ry: 2px;
- }
- .node-selector-box.checked {
- fill: #0d6efd;
- stroke: #0d6efd;
- }
- .node-selector-mark {
- font-size: 10px;
- fill: #fff;
- font-family: Arial, sans-serif;
- pointer-events: none;
- }
- .selected-member-chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 4px 8px;
- border: 1px solid #ced4da;
- border-radius: 12px;
- margin: 4px 6px 0 0;
- font-size: 13px;
- background: #f8f9fa;
- }
- .selected-member-chip button {
- border: none;
- background: transparent;
- color: #dc3545;
- font-size: 14px;
- line-height: 1;
- padding: 0 2px;
- cursor: pointer;
- }
-
- .node-spouse {
- font-size: 12px;
- fill: #555;
- font-family: "KaiTi", "SimSun", serif;
- }
- .node-mark {
- font-size: 12px;
- fill: #d32f2f;
- font-family: "Microsoft YaHei", sans-serif;
- }
-
- .rel-text {
- font-size: 12px;
- fill: #666;
- font-family: "KaiTi", "SimSun", serif;
- }
- </style>
- </head>
- <body>
- <div class="container-fluid">
- <div class="toolbar d-print-none">
- <div>
- <a href="/manager/tree" class="btn btn-outline-secondary me-2"><i class="bi bi-arrow-left"></i> 返回</a>
- <h4 class="d-inline mb-0 align-middle">传统吊线图预览</h4>
- </div>
- <div>
- <button class="btn btn-outline-primary me-2" data-bs-toggle="modal" data-bs-target="#memberSelectModal">
- <i class="bi bi-person-lines-fill"></i> 选择起始人员
- </button>
- <button id="btnExport" class="btn btn-primary"><i class="bi bi-download"></i> 导出为图片</button>
- </div>
- </div>
- <div id="loading" class="text-center py-5">
- <div class="spinner-border text-primary" role="status"></div>
- <p class="mt-2 text-muted">正在加载并生成排版...</p>
- </div>
- <div class="export-container" id="exportArea">
- <!-- 页面块将通过JS生成插入到这里 -->
- </div>
- </div>
- <!-- 人员选择 Modal -->
- <div class="modal fade" id="memberSelectModal" tabindex="-1" aria-hidden="true">
- <div class="modal-dialog modal-dialog-scrollable">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">选择要导出的起始人员(祖先)</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
- </div>
- <div class="modal-body">
- <div class="mb-3">
- <div class="small text-muted mb-1">已勾选起始人员(可删除)</div>
- <div id="selectedMembersPanel"></div>
- </div>
- <input type="text" id="memberSearch" class="form-control mb-3" placeholder="搜索姓名...">
- <div class="mb-2">
- <button class="btn btn-sm btn-outline-secondary" onclick="selectAllMembers(true)">全选</button>
- <button class="btn btn-sm btn-outline-secondary" onclick="selectAllMembers(false)">全不选</button>
- <span class="text-muted small ms-2">可搜索增加人员;已勾选人员会显示在上方</span>
- </div>
- <div id="memberList" class="list-group">
- <!-- 成员复选框列表 -->
- </div>
- </div>
- <div class="modal-footer">
- <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
- <button type="button" class="btn btn-primary" onclick="drawSelectedTree()">开始绘制</button>
- </div>
- </div>
- </div>
- </div>
- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
-
- <script>
- // 配置常量
- const CONFIG = {
- MAX_GEN_PER_PAGE: 10, // 每页最多显示的代数
- X_STEP: 55, // 列宽
- Y_STEP: 160, // 行高 (代与代之间的距离)
- NODE_WIDTH: 24, // 名字竖排的估计宽度
- NODE_HEIGHT: 80, // 名字竖排的估计高度
- MARGIN_LEFT: 60, // 左侧世代标记留白
- MARGIN_TOP: 20, // 顶部留白
- MARGIN_BOTTOM: 40, // 底部留白
- LINE_MID_OFFSET: 40 // 横线距离父节点底部的距离
- };
- let totalPagesCount = 0;
- let allMembersData = [];
- let allRelationsData = [];
- let selectedRootIdsSet = new Set();
- let showChartSelectors = true; // 图中快速勾选框显示开关
- document.addEventListener('DOMContentLoaded', function() {
- loadTreeData();
- // Search filter for member list
- document.getElementById('memberSearch').addEventListener('input', function() {
- const term = this.value.trim().toLowerCase();
- const labels = document.querySelectorAll('#memberList label');
- labels.forEach(lbl => {
- const text = lbl.textContent.toLowerCase();
- if (text.includes(term)) {
- lbl.style.display = 'block';
- } else {
- lbl.style.display = 'none';
- }
- });
- });
- document.getElementById('memberSelectModal').addEventListener('show.bs.modal', function() {
- syncCheckboxesFromSelectedSet();
- renderSelectedMembersPanel();
- });
- document.getElementById('btnExport').addEventListener('click', function() {
- const element = document.getElementById('exportArea');
- const btn = this;
- const originalHtml = btn.innerHTML;
-
- btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在渲染...';
- btn.disabled = true;
- setTimeout(() => {
- html2canvas(element, {
- scale: 2,
- backgroundColor: '#f0f2f5',
- logging: false
- }).then(canvas => {
- const imgData = canvas.toDataURL('image/jpeg', 0.9);
- const link = document.createElement('a');
- link.download = '家谱吊线图_' + new Date().getTime() + '.jpg';
- link.href = imgData;
- link.click();
-
- btn.innerHTML = originalHtml;
- btn.disabled = false;
- }).catch(err => {
- console.error('导出失败:', err);
- alert('导出失败,请重试。');
- btn.innerHTML = originalHtml;
- btn.disabled = false;
- });
- }, 500);
- });
- });
- function selectAllMembers(checked) {
- document.querySelectorAll('.member-checkbox').forEach(cb => {
- if (cb.parentElement.style.display !== 'none') {
- cb.checked = checked;
- }
- });
- syncSelectedSetFromCheckboxes();
- renderSelectedMembersPanel();
- }
- function syncSelectedSetFromCheckboxes() {
- selectedRootIdsSet = new Set(
- Array.from(document.querySelectorAll('.member-checkbox:checked'))
- .map(cb => parseInt(cb.value))
- .filter(v => !Number.isNaN(v))
- );
- }
- function syncCheckboxesFromSelectedSet() {
- document.querySelectorAll('.member-checkbox').forEach(cb => {
- const id = parseInt(cb.value);
- cb.checked = selectedRootIdsSet.has(id);
- });
- }
- function renderSelectedMembersPanel() {
- const panel = document.getElementById('selectedMembersPanel');
- if (!panel) return;
- const selectedMembers = allMembersData.filter(m => selectedRootIdsSet.has(m.id));
- if (selectedMembers.length === 0) {
- panel.innerHTML = '<span class="text-muted small">当前未勾选任何人员</span>';
- return;
- }
- panel.innerHTML = selectedMembers.map(m => `
- <span class="selected-member-chip">
- <span>${m.name || ('ID:' + m.id)}</span>
- <button type="button" data-remove-id="${m.id}" title="移除">×</button>
- </span>
- `).join('');
- panel.querySelectorAll('button[data-remove-id]').forEach(btn => {
- btn.addEventListener('click', function() {
- const id = parseInt(this.dataset.removeId);
- if (Number.isNaN(id)) return;
- selectedRootIdsSet.delete(id);
- syncCheckboxesFromSelectedSet();
- renderSelectedMembersPanel();
- });
- });
- }
- function drawSelectedTree() {
- const selectedIds = Array.from(document.querySelectorAll('.member-checkbox:checked')).map(cb => parseInt(cb.value));
- if (selectedIds.length === 0) {
- alert('请至少选择一位起始人员。');
- return;
- }
- selectedRootIdsSet = new Set(selectedIds);
- showChartSelectors = false; // 点击“开始绘制”后进入正式输出模式,不显示勾选框
-
- // Close modal
- const modalEl = document.getElementById('memberSelectModal');
- const modal = bootstrap.Modal.getInstance(modalEl);
- if(modal) modal.hide();
-
- document.getElementById('loading').style.display = 'block';
- document.getElementById('exportArea').innerHTML = '';
-
- // Allow UI to update before heavy computation
- setTimeout(() => {
- buildAndRenderPages(allMembersData, allRelationsData, selectedIds);
- }, 100);
- }
- async function loadTreeData() {
- try {
- const response = await fetch('/manager/api/tree_data');
- const data = await response.json();
- if (data.error) return alert('获取数据失败: ' + data.error);
-
- allMembersData = data.members;
- allRelationsData = data.relations;
-
- // Populate member list in modal
- const listHtml = allMembersData.map(m => {
- const gen = m.family_rank ? ` (排行/代数: ${m.family_rank})` : '';
- return `
- <label class="list-group-item">
- <input class="form-check-input me-1 member-checkbox" type="checkbox" value="${m.id}">
- ${m.name}${gen}
- </label>
- `;
- }).join('');
- document.getElementById('memberList').innerHTML = listHtml;
- document.querySelectorAll('.member-checkbox').forEach(cb => {
- cb.addEventListener('change', syncSelectedSetFromCheckboxes);
- cb.addEventListener('change', renderSelectedMembersPanel);
- });
-
- // default build using empty selection (will find absolute roots)
- buildAndRenderPages(data.members, data.relations, []);
- } catch (error) {
- console.error(error);
- document.getElementById('loading').innerHTML = '<span class="text-danger">加载失败,请检查网络并重试。</span>';
- }
- }
- function extractGen(rankStr) {
- if (!rankStr) return null;
- const match = String(rankStr).match(/\d+/);
- return match ? parseInt(match[0]) : null;
- }
- function buildAndRenderPages(members, relations, selectedRootIds) {
- // 1. 构建树结构
- const memberMap = {};
- // Deep clone members to avoid state pollution between multiple drawings
- const clonedMembers = members.map(m => ({...m, children: [], spouses: []}));
-
- clonedMembers.forEach(m => {
- m.gen = extractGen(m.family_rank);
- memberMap[m.id] = m;
- });
- relations.forEach(rel => {
- const parent = memberMap[rel.parent_mid];
- const child = memberMap[rel.child_mid];
- if (!parent || !child) return;
- if (rel.relation_type === 1 || rel.relation_type === 2) {
- parent.children.push(child);
- child._hasParent = true;
- } else if (rel.relation_type === 10) {
- parent.spouses.push(child);
- child._isSpouse = true;
- }
- });
- // 寻找根节点并推断代数
- let roots = [];
- if (selectedRootIds && selectedRootIds.length > 0) {
- roots = selectedRootIds.map(id => memberMap[id]).filter(m => m);
- } else {
- roots = clonedMembers.filter(m => !m._hasParent && !m._isSpouse);
- }
-
- // 递归填充缺失的代数
- function fillGen(node, currentGen) {
- if (node.gen === null || isNaN(node.gen)) {
- node.gen = currentGen;
- }
- node.children.forEach(c => fillGen(c, node.gen + 1));
- }
-
- // 如果根节点也没有代数,默认给个1
- roots.forEach(r => fillGen(r, r.gen || 1));
- // 按照世代对roots进行排序,优先显示最老的祖先
- roots.sort((a, b) => a.gen - b.gen);
- document.getElementById('loading').style.display = 'none';
- const container = document.getElementById('exportArea');
-
- if (roots.length === 0) {
- container.innerHTML = '<div class="text-muted">暂无家谱数据。</div>';
- return;
- }
- // 2. 分页切块逻辑
- const pageQueue = [];
- // 将第一批 roots 放入第一页
- if (roots.length > 0) {
- let minGen = Math.min(...roots.map(r => r.gen));
- pageQueue.push({
- nodes: roots,
- startGen: minGen,
- leftTitle: '',
- pageId: 1
- });
- }
- let pagesRendered = [];
- let nextPageId = 2;
- while(pageQueue.length > 0) {
- let currentJob = pageQueue.shift();
-
- // 克隆当前块的节点树,遇到超过 MAX_GEN_PER_PAGE 的裁剪并生成新任务
- let maxGenForThisPage = currentJob.startGen + CONFIG.MAX_GEN_PER_PAGE - 1;
- let blockNodes = cloneAndClip(currentJob.nodes, maxGenForThisPage, pageQueue);
-
- pagesRendered.push({
- id: currentJob.pageId,
- nodes: blockNodes,
- startGen: currentJob.startGen,
- endGen: maxGenForThisPage,
- leftTitle: currentJob.leftTitle
- });
- }
- totalPagesCount = pagesRendered.length;
- // 3. 渲染页面
- pagesRendered.forEach((page, index) => {
- const pageHtml = renderPageSVG(page, index + 1, totalPagesCount);
- container.insertAdjacentHTML('beforeend', pageHtml);
- });
- bindQuickSelectOnChartNames();
- syncCheckboxesFromSelectedSet();
-
- // Function to clip deep branches
- function cloneAndClip(nodes, maxGen, queue) {
- return nodes.map(n => {
- let clone = { ...n };
- // 当当前节点已达到本页最大代数,且还有子节点时,截断并送入下一页
- if (clone.gen >= maxGen && clone.children && clone.children.length > 0) {
- clone.hasNextPage = true;
- clone.nextPageLink = nextPageId;
-
- queue.push({
- nodes: clone.children,
- startGen: clone.gen + 1,
- leftTitle: `上接 ${clone.name}`,
- pageId: nextPageId
- });
- nextPageId++;
- clone.children = [];
- } else if (clone.children && clone.children.length > 0) {
- clone.children = cloneAndClip(clone.children, maxGen, queue);
- }
- return clone;
- });
- }
- }
- // SVG 生成核心逻辑
- function renderPageSVG(page, pageNum, totalPages) {
- let currentX = 0;
- const nodesFlat = [];
- const lines = [];
- // 1. 布局计算 (核心算法: 父亲对齐长子,从右向左排版 RTL)
- function layout(node, depthY) {
- if (!node.children || node.children.length === 0) {
- node.x = currentX;
- node.y = depthY;
- currentX -= CONFIG.X_STEP; // 向左递减,实现从右到左排版
- } else {
- node.children.forEach(child => layout(child, depthY + 1));
- node.x = node.children[0].x; // 父亲与长子对齐
- node.y = depthY;
- }
- nodesFlat.push(node);
- }
- // 对 page.nodes 进行布局
- page.nodes.forEach(root => layout(root, 0));
- // 获取所需的实际尺寸
- const minX = currentX; // 因为向左排布,最小X是负数
- const maxDepth = Math.max(...nodesFlat.map(n => n.y), 0);
-
- // 坐标平移,把所有负数X转为正数,并在右侧留白
- const offsetX = Math.abs(minX) + CONFIG.MARGIN_LEFT + 50;
-
- const svgWidth = offsetX + 100; // 总宽度
- const svgHeight = (maxDepth + 1) * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.MARGIN_BOTTOM;
- // 2. 构建连线
- nodesFlat.forEach(node => {
- if (node.children && node.children.length > 0) {
- const firstChild = node.children[0];
- const lastChild = node.children[node.children.length - 1];
- // 从父亲到底部横线
- const pX = node.x + offsetX;
- const pY = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + CONFIG.NODE_HEIGHT;
- const midY = pY + CONFIG.LINE_MID_OFFSET;
- lines.push(`<line class="line" x1="${pX}" y1="${pY}" x2="${pX}" y2="${midY}" />`);
- // 绘制子节点水平连线
- const firstX = firstChild.x + offsetX;
- const lastX = lastChild.x + offsetX;
- if (firstX !== lastX) {
- lines.push(`<line class="line" x1="${firstX}" y1="${midY}" x2="${lastX}" y2="${midY}" />`);
- }
- // 绘制连接到每个子节点的垂线
- node.children.forEach((child, i) => {
- const cX = child.x + offsetX;
- const cY = child.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
- lines.push(`<line class="line" x1="${cX}" y1="${midY}" x2="${cX}" y2="${cY}" />`);
-
- // 右侧标记“长子”、“次子” (因为从右到左,字应该写在左边或者右边)
- // 传统家谱写在线右侧
- const relLabels = ['长子', '次子', '三子', '四子', '五子', '六子', '七子', '八子'];
- let relLabel = relLabels[i] || '子';
- if(child.sex === 2) relLabel = '女';
-
- // 竖排显示长子、次子
- lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 15}">${relLabel[0]}</text>`);
- if(relLabel[1]) {
- lines.push(`<text class="rel-text" x="${cX + 8}" y="${midY + 28}">${relLabel[1]}</text>`);
- }
- });
- }
- });
- // 3. 构建节点文字
- const nodesHtml = nodesFlat.map(node => {
- const nx = node.x + offsetX;
- const ny = node.y * CONFIG.Y_STEP + CONFIG.MARGIN_TOP;
- const isSelectedRoot = selectedRootIdsSet.has(node.id);
-
- let html = '';
-
- // 姓名竖排处理 (简单切分文字为数组)
- let nameArr = Array.from(node.name || '未知');
- nameArr.forEach((char, i) => {
- const cls = `node-text${isSelectedRoot ? ' selected-root-text' : ''}`;
- html += `<text class="${cls}" data-member-id="${node.id}" x="${nx}" y="${ny + 16 + i * 18}" text-anchor="middle">${char}</text>`;
- });
- // 人名上方勾选框(用于快速选择起始人员)
- if (showChartSelectors && node.id !== undefined && node.id !== null) {
- const checkedCls = isSelectedRoot ? 'checked' : '';
- html += `
- <g class="node-selector" data-member-id="${node.id}" transform="translate(${nx - 6}, ${ny - 14})">
- <rect class="node-selector-box ${checkedCls}" width="12" height="12"></rect>
- ${isSelectedRoot ? '<text class="node-selector-mark" x="6" y="9" text-anchor="middle">✓</text>' : ''}
- </g>
- `;
- }
- // 配偶信息(放在名字左侧)
- if (node.spouses && node.spouses.length > 0) {
- const spNames = node.spouses.map(s => s.name).join('、');
- let spStr = '配' + spNames;
- Array.from(spStr).forEach((char, i) => {
- // 左侧(X减小)
- html += `<text class="node-spouse" x="${nx - 18}" y="${ny + 12 + i * 14}" text-anchor="middle">${char}</text>`;
- });
- }
- // 标记下一页
- if (node.hasNextPage) {
- html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 25}" text-anchor="middle">下接</text>`;
- html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 40}" text-anchor="middle">第</text>`;
- html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 55}" text-anchor="middle">${node.nextPageLink}</text>`;
- html += `<text class="node-mark" x="${nx}" y="${ny + CONFIG.NODE_HEIGHT + 70}" text-anchor="middle">页</text>`;
- }
- return html;
- }).join('');
- // 4. 构建左侧世代标记
- let genLabels = '';
- for(let i=0; i<=maxDepth; i++) {
- const actualGen = page.startGen + i;
- const y = i * CONFIG.Y_STEP + CONFIG.MARGIN_TOP + 15;
- const x = 30; // 左侧留白
-
- // 将代数竖排
- let genStr = actualGen + '世';
- // 支持如"30世"
- genLabels += `<text class="gen-text" x="${x}" y="${y}" text-anchor="middle">${actualGen}</text>`;
- genLabels += `<text class="gen-text" x="${x}" y="${y + 18}" text-anchor="middle">世</text>`;
-
- // 画一条灰色的分隔虚线(非必须,但好看)
- genLabels += `<line x1="10" y1="${y-20}" x2="${svgWidth}" y2="${y-20}" stroke="#eee" stroke-dasharray="4" />`;
- }
- return `
- <div class="page-block">
- <div class="page-header">
- <div class="page-left-title">${page.leftTitle}</div>
- <div class="page-title">传统家谱吊线图</div>
- <div class="page-subtitle">共 ${totalPages} 页 第 ${pageNum} 页</div>
- </div>
- <svg class="tree-svg" width="${svgWidth}" height="${svgHeight}">
- <!-- 背景和网格 -->
- <rect width="100%" height="100%" fill="white" />
- ${genLabels}
- <!-- 连线 -->
- ${lines.join('\n')}
- <!-- 节点 -->
- ${nodesHtml}
- </svg>
- </div>`;
- }
- function applySelectionStateToChart(memberId) {
- const selected = selectedRootIdsSet.has(memberId);
- document.querySelectorAll(`.node-text[data-member-id="${memberId}"]`).forEach(t => {
- t.classList.toggle('selected-root-text', selected);
- });
- document.querySelectorAll(`.node-selector[data-member-id="${memberId}"]`).forEach(g => {
- const rect = g.querySelector('.node-selector-box');
- if (rect) rect.classList.toggle('checked', selected);
- const mark = g.querySelector('.node-selector-mark');
- if (selected && !mark) {
- const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- textEl.setAttribute('class', 'node-selector-mark');
- textEl.setAttribute('x', '6');
- textEl.setAttribute('y', '9');
- textEl.setAttribute('text-anchor', 'middle');
- textEl.textContent = '✓';
- g.appendChild(textEl);
- } else if (!selected && mark) {
- mark.remove();
- }
- });
- }
- function toggleSelection(memberId) {
- if (Number.isNaN(memberId)) return;
- if (selectedRootIdsSet.has(memberId)) selectedRootIdsSet.delete(memberId);
- else selectedRootIdsSet.add(memberId);
- applySelectionStateToChart(memberId);
- syncCheckboxesFromSelectedSet();
- renderSelectedMembersPanel();
- }
- function bindQuickSelectOnChartNames() {
- if (!showChartSelectors) return;
- // 勾选框点击:仅切换勾选状态,不重绘、不跳页
- const selectors = document.querySelectorAll('.node-selector[data-member-id]');
- selectors.forEach(el => {
- el.addEventListener('click', function(event) {
- event.stopPropagation();
- const id = parseInt(this.dataset.memberId);
- toggleSelection(id);
- });
- });
- // 人名点击:同样仅切换勾选状态
- const names = document.querySelectorAll('.node-text[data-member-id]');
- names.forEach(el => {
- el.addEventListener('click', function(event) {
- event.stopPropagation();
- const id = parseInt(this.dataset.memberId);
- toggleSelection(id);
- });
- });
- }
- </script>
- </body>
- </html>
|