familytree 家族亲缘 :root { --bg: #fbfaf6; --paper: #ffffff; --paper2: #fffdf8; --ink: #2b2620; --ink-soft: #8a7a64; --red: #b3242a; --black: #2a2a2a; --line: #9b8a6e; --gold: #b08d4f; --border: #e7ddca; --wash-a: rgba(176, 141, 79, .10); --wash-b: rgba(179, 36, 42, .08); --fiber: rgba(110, 88, 54, .035); } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; } body { font-family: "KaiTi", "STKaiti", "楷体", "SimSun", "宋体", serif; color: var(--ink); background: radial-gradient(circle at 15% 8%, var(--wash-a), transparent 38%), radial-gradient(circle at 85% 92%, var(--wash-b), transparent 40%), repeating-linear-gradient(0deg, var(--fiber) 0 1px, transparent 1px 7px), var(--bg); min-height: 100vh; } .frame { position: relative; max-width: 1180px; margin: 22px auto; background: linear-gradient(135deg, var(--wash-a), transparent 24%, transparent 76%, var(--wash-b)), repeating-linear-gradient(90deg, var(--fiber) 0 1px, transparent 1px 9px), var(--paper); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 12px 34px rgba(120, 90, 40, .14), inset 0 0 0 1px #fff, inset 0 0 0 6px rgba(179, 36, 42, .05); padding: 24px 28px 30px; } .seal { position: absolute; top: 22px; right: 26px; width: 54px; height: 54px; border-radius: 8px; background: var(--red); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 30px; letter-spacing: 0; box-shadow: 0 3px 8px rgba(179, 36, 42, .38); transform: rotate(-5deg); } .corner { position: absolute; width: 22px; height: 22px; border: 2px solid var(--gold); opacity: .55; } .corner.tl { top: 10px; left: 10px; border-right: none; border-bottom: none; } .corner.tr { top: 10px; right: 10px; border-left: none; border-bottom: none; } .corner.bl { bottom: 10px; left: 10px; border-right: none; border-top: none; } .corner.br { bottom: 10px; right: 10px; border-left: none; border-top: none; } .page-ornament { position: absolute; pointer-events: none; z-index: 0; opacity: .22; } .page-ornament.clouds { left: 28px; top: 78px; width: 150px; height: 50px; opacity: .16; } .page-ornament.branch { right: 22px; bottom: 16px; width: 150px; height: 104px; opacity: .11; } header.banner, .toolbar, .main { position: relative; z-index: 1; } .corner, .seal { z-index: 2; } header.banner { text-align: center; border-bottom: 1px solid var(--border); padding-bottom: 14px; margin-bottom: 16px; } header.banner h1 { margin: 0; font-size: 44px; letter-spacing: 16px; text-indent: 16px; color: var(--red); text-shadow: 1px 1px 0 rgba(179, 36, 42, .12); } header.banner .loc { font-size: 16px; color: var(--ink-soft); letter-spacing: 3px; margin-bottom: 4px; } header.banner .sub { font-size: 14px; color: var(--ink-soft); letter-spacing: 4px; margin-top: 6px; } .divider { width: 140px; height: 0; margin: 10px auto 0; border-top: 1px solid var(--gold); position: relative; } .divider::after { content: "❖"; position: absolute; top: -11px; left: 50%; transform: translateX(-50%); background: var(--paper); color: var(--gold); padding: 0 8px; font-size: 13px; } .toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; } button, select, input, textarea { font-family: inherit; } .btn { background: var(--paper2); color: var(--ink); border: 1px solid var(--gold); border-radius: 6px; padding: 6px 13px; cursor: pointer; font-size: 15px; transition: .15s; } .btn:hover { background: #fff7e9; border-color: var(--red); } .btn.active { background: var(--red); color: #fff; border-color: var(--red); } .btn.ghost { border-color: var(--border); color: var(--ink-soft); } .btn.ghost:hover { border-color: var(--gold); color: var(--ink); } .btn.danger { border-color: var(--red); color: var(--red); } .btn.danger:hover { background: #fdecec; } .spacer { flex: 1; } .toolbar label { font-size: 14px; color: var(--ink-soft); } .toolbar input[type=text] { border: 1px solid var(--border); border-radius: 5px; padding: 5px 8px; font-size: 14px; background: #fff; color: var(--ink); } .toolbar select { border: 1px solid var(--border); border-radius: 5px; padding: 5px 8px; font-size: 14px; background: #fff; color: var(--ink); } .legend { font-size: 13px; color: var(--ink-soft); display: flex; gap: 16px; align-items: center; flex-wrap: wrap; } .legend .dot { display: inline-block; width: 13px; height: 13px; border-radius: 3px; vertical-align: -2px; margin-right: 5px; border: 2px solid; } .legend .red { border-color: var(--red); } .legend .black { border-color: var(--black); } .legend .dbl { display: inline-block; width: 22px; height: 0; border-top: 3px double var(--red); vertical-align: 4px; margin-right: 5px; } .legend .dash { display: inline-block; width: 13px; height: 13px; border-radius: 3px; vertical-align: -2px; margin-right: 5px; border: 2px dashed var(--gold); } #entryPanel { background: var(--paper2); border: 1px dashed var(--border); border-radius: 8px; padding: 16px 18px; margin-bottom: 14px; height: 280px; min-height: 160px; max-height: 60vh; overflow-y: auto; } #entryPanel h3 { margin: 0 0 12px; color: var(--red); letter-spacing: 3px; } .form-grid { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; } .field { display: flex; flex-direction: column; gap: 5px; } .field label { font-size: 13px; color: var(--ink-soft); } .field input, .field select, .field textarea { border: 1px solid var(--border); border-radius: 5px; padding: 6px 9px; font-size: 15px; background: #fff; color: var(--ink); } .field input[type=date] { font-family: inherit; } .field textarea { resize: vertical; min-height: 40px; min-width: 210px; } .gender-pick { display: flex; gap: 12px; } .gender-pick label { font-size: 15px; color: var(--ink); cursor: pointer; } .hint { font-size: 12px; color: var(--ink-soft); margin-top: 8px; line-height: 1.6; } .field-sep { width: 1px; height: 20px; background: var(--border); margin: 0 2px; } .chk { font-size: 13px; color: var(--ink-soft); display: flex; align-items: center; gap: 3px; cursor: pointer; } .chk input { margin: 0; } #memberList { margin-top: 16px; } #memberList table { width: 100%; border-collapse: collapse; font-size: 14px; } #memberList th, #memberList td { border: 1px solid var(--border); padding: 6px 9px; text-align: left; } #memberList th { background: #faf3e6; color: var(--ink); } .mini-btn { padding: 3px 9px; font-size: 13px; border-radius: 4px; border: 1px solid var(--border); background: #fff; cursor: pointer; } .mini-btn.del { color: var(--red); border-color: var(--red); } .view-head { display: flex; align-items: center; gap: 10px; margin: 6px 0 10px; flex-wrap: wrap; } .scroll-wrap { overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px; cursor: grab; text-align: center; background: radial-gradient(circle at 8% 12%, var(--wash-a), transparent 28%), radial-gradient(circle at 92% 88%, var(--wash-b), transparent 30%), var(--paper); } svg { display: block; margin: 0 auto; } text { user-select: none; } .mask { position: fixed; inset: 0; background: rgba(40, 20, 5, .4); display: none; align-items: center; justify-content: center; z-index: 50; } .mask.show { display: flex; } .modal { background: var(--paper); border: 1px solid var(--gold); border-radius: 12px; width: min(440px, 92vw); padding: 18px 20px; max-height: 86vh; overflow: auto; box-shadow: 0 16px 40px rgba(90, 40, 10, .3), inset 0 0 0 5px rgba(179, 36, 42, .05); } .modal h3 { margin: 0 0 8px; color: var(--red); letter-spacing: 3px; font-size: 22px; } .modal .meta { font-size: 14px; color: var(--ink-soft); margin-bottom: 12px; line-height: 1.6; } .rel-group { margin: 10px 0; } .rel-group .title { font-weight: bold; color: var(--ink); border-left: 4px solid var(--gold); padding-left: 7px; margin-bottom: 6px; } .rel-group .items { display: flex; flex-wrap: wrap; gap: 6px; } .rel-chip { border: 1px solid var(--line); background: #fff; border-radius: 14px; padding: 4px 11px; font-size: 14px; cursor: pointer; } .rel-chip.f { color: var(--red); border-color: var(--red); } .rel-chip.m { color: var(--black); } .rel-chip:hover { background: #fff7e9; } .empty { color: var(--ink-soft); font-style: italic; } .modal-actions { margin-top: 14px; text-align: right; } .toast { position: fixed; left: 50%; bottom: 30px; transform: translateX(-50%); background: var(--ink); color: #fff; padding: 9px 18px; border-radius: 22px; font-size: 14px; opacity: 0; transition: .3s; pointer-events: none; z-index: 99; } .toast.show { opacity: .95; } /* ---------- 全屏展示 ---------- */ .fullscreen-mask { position: fixed; inset: 0; background: var(--bg); z-index: 100; display: none; flex-direction: column; } .fullscreen-mask.show { display: flex; } .fs-bar { display: flex; align-items: center; gap: 12px; padding: 10px 20px; border-bottom: 1px solid var(--border); background: var(--paper); } .fs-bar strong { color: var(--red); letter-spacing: 3px; font-size: 20px; } .fs-wrap { flex: 1; min-height: 0; overflow: auto; padding: 20px; } #fsContent { margin: 0 auto; width: max-content; } /* ---------- responsive ---------- */ .main { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; } /* 移动端:录入栏在上,竖排分割线(row-resize) */ .splitter { display: none; height: 7px; flex-shrink: 0; cursor: row-resize; position: relative; touch-action: none; } .splitter::after { content: ""; position: absolute; top: 50%; left: 4px; right: 4px; height: 3px; background: var(--border); border-radius: 2px; transform: translateY(-50%); transition: background .15s; } .splitter:hover::after { background: var(--gold); } .scroll-wrap { max-height: 62vh; } #memberList { overflow-x: auto; } #memberList table { min-width: 280px; } @media (min-width:900px) { html, body { height: 100%; } body { overflow: hidden; } .frame { max-width: none; height: 100vh; margin: 0; border-radius: 0; display: flex; flex-direction: column; overflow: hidden; padding: 10px 16px 12px; } .frame .seal { width: 42px; height: 42px; font-size: 22px; top: 12px; right: 16px; } header.banner { padding-bottom: 8px; margin-bottom: 8px; } header.banner h1 { font-size: 30px; letter-spacing: 12px; text-indent: 12px; } header.banner .sub { font-size: 12px; letter-spacing: 3px; margin-top: 2px; } .divider { margin-top: 6px; } .toolbar { margin-bottom: 8px; } .main { flex-direction: row; } /* 桌面:录入栏在左,竖线分割(col-resize) */ .splitter { display: block; width: 7px; height: auto; cursor: col-resize; align-self: stretch; } .splitter::after { top: 4px; bottom: 4px; left: 50%; right: auto; width: 3px; height: auto; transform: translateX(-50%); } #entryPanel { width: 360px; min-width: 240px; max-width: 60vw; height: auto; max-height: none; margin-bottom: 0; overflow-y: auto; flex: 0 0 auto; } #viewPanel { flex: 1; min-width: 0; display: flex; flex-direction: column; } .view-head { margin: 0 0 8px; } .scroll-wrap { flex: 1; min-height: 0; max-height: none; } } @media (max-width:599px) { .frame { padding: 12px; } header.banner h1 { font-size: 26px; letter-spacing: 8px; text-indent: 8px; } .btn { padding: 9px 12px; font-size: 15px; } .field input, .field select, .field textarea { font-size: 16px; padding: 8px 10px; } .form-grid { gap: 10px; } .field textarea { min-width: 0; } .mini-btn { padding: 6px 12px; font-size: 14px; } .seal { display: none; } .scroll-wrap { max-height: 56vh; } } 族 亲缘 承先启后 · 维系亲缘 + 录入成员 横向布局 竖排布局 分级布局 导出 导入 载入示例 清空 女 男 夫妻(双线) 异姓(虚线) 亲缘 新建亲缘 保存亲缘 删除 祖籍 姓氏 主题 宣纸 朱印 青绿 夜阑 水墨 青玉 秋山 莲华 字体 楷体 宋体 黑体 仿宋 隶书 线宽 细 中 粗 字号 - + 异姓虚线 展示: 排行 生辰 卒日 去世 备注 导出图片 导出表格 全屏展示 录入家族成员 姓名 * 性别 男 女 出生日期 去世日期 是否去世(展示页面显示「卒」) 排行 备注 亲缘关系 保持现有关系 解除关系(改为独立) 作为某人的子女 作为某人的配偶 作为某人的父母 保存成员 规则:夫妻须一男一女、非血亲;子女出生不得早于父辈;同辈按「排行」降序排列(排行 1=老大居最右),未填排行则按出生日期排序。作为子女时,选父亲即自动填入其配偶为母亲、选母亲自动填入其配偶为父亲,改选任一方另一方随之更新;已婚者再添配偶记为二任、三任……。编辑成员可改关系或用「解除关系」改为独立;全局图中可拖动成员框(限图框内),点「重置布局」还原。 全局亲缘 适应窗口 - + 刷新预览 重置布局 分级家庭 焦点: 横版 竖版 查看上一代 查看子女 查看配偶 适应窗口 刷新预览 亲缘 + - 适应 ✕ 返回 "use strict"; const SVGNS = "http://www.w3.org/2000/svg"; const STORE = "familytree_trees_v1"; const HCFG = { W: 108, H: 78, COUPLEGAP: 46, CHILDGAP: 42, ROWH: 170, MARGIN: 44 }; const VCFG = { W: 86, H: 150, COUPLEGAP: 26, CHILDGAP: 44, ROWH: 210, MARGIN: 44 }; const W = HCFG.W, H = HCFG.H, COUPLEGAP = HCFG.COUPLEGAP, CHILDGAP = HCFG.CHILDGAP, ROWH = HCFG.ROWH, MARGIN = HCFG.MARGIN; const VW = VCFG.W, VH = VCFG.H; /* ---------- 主题与字体 ---------- */ const THEMES = { xuan: { "--bg": "#eee5d2", "--paper": "#fffaf0", "--paper2": "#f8edd8", "--deceased": "#ded5c5", "--ink": "#352b20", "--ink-soft": "#826e55", "--red": "#a82925", "--black": "#28231e", "--line": "#9b8060", "--gold": "#b18a45", "--border": "#d9c59f", "--wash-a": "rgba(172,132,62,.18)", "--wash-b": "rgba(161,45,37,.11)", "--fiber": "rgba(94,66,35,.055)" }, zhu: { "--bg": "#ead4ca", "--paper": "#fff3ec", "--paper2": "#f8ddd1", "--deceased": "#d9c1b6", "--ink": "#54241f", "--ink-soft": "#9b5b50", "--red": "#a20f18", "--black": "#3b211f", "--line": "#ba6655", "--gold": "#c3934b", "--border": "#d8a292", "--wash-a": "rgba(184,43,38,.22)", "--wash-b": "rgba(215,142,91,.18)", "--fiber": "rgba(114,35,31,.045)" }, qing: { "--bg": "#dce9df", "--paper": "#f2f8ef", "--paper2": "#e1eee0", "--deceased": "#c7d4c8", "--ink": "#17372d", "--ink-soft": "#4f7564", "--red": "#9a322f", "--black": "#193c32", "--line": "#508875", "--gold": "#9b8242", "--border": "#aacbb7", "--wash-a": "rgba(45,121,87,.20)", "--wash-b": "rgba(153,126,58,.13)", "--fiber": "rgba(34,91,67,.045)" }, ye: { "--bg": "#111622", "--paper": "#1c2332", "--paper2": "#252e40", "--deceased": "#424958", "--ink": "#f1e7cf", "--ink-soft": "#b9aa8b", "--red": "#df726f", "--black": "#e4dcc8", "--line": "#8ca0b8", "--gold": "#e2bd63", "--border": "#465268", "--wash-a": "rgba(55,91,137,.28)", "--wash-b": "rgba(202,164,77,.14)", "--fiber": "rgba(219,226,234,.025)" }, shui: { "--bg": "#d9dcda", "--paper": "#f3f4ef", "--paper2": "#e5e8e3", "--deceased": "#c8cdca", "--ink": "#171d1a", "--ink-soft": "#5e6963", "--red": "#85312e", "--black": "#111614", "--line": "#6d7771", "--gold": "#82775d", "--border": "#adb5b0", "--wash-a": "rgba(38,50,44,.20)", "--wash-b": "rgba(91,105,96,.15)", "--fiber": "rgba(25,31,28,.055)" }, yu: { "--bg": "#d3e9e3", "--paper": "#effaf5", "--paper2": "#dcefe8", "--deceased": "#c1d8d0", "--ink": "#103d34", "--ink-soft": "#477a6d", "--red": "#a13738", "--black": "#16483d", "--line": "#4e9a84", "--gold": "#b09043", "--border": "#9fcfc0", "--wash-a": "rgba(29,145,114,.20)", "--wash-b": "rgba(74,190,155,.15)", "--fiber": "rgba(18,87,69,.04)" }, qiu: { "--bg": "#ead7bc", "--paper": "#fff4df", "--paper2": "#f2dfc0", "--deceased": "#d8c2a4", "--ink": "#4b2919", "--ink-soft": "#8f6040", "--red": "#a63222", "--black": "#3c281d", "--line": "#aa6d3e", "--gold": "#bd7b27", "--border": "#d6ad72", "--wash-a": "rgba(207,112,28,.24)", "--wash-b": "rgba(159,42,28,.18)", "--fiber": "rgba(106,56,20,.045)" }, lian: { "--bg": "#eadce2", "--paper": "#fff4f7", "--paper2": "#f4dfe7", "--deceased": "#d9c6ce", "--ink": "#51263a", "--ink-soft": "#8e5a70", "--red": "#a61f4a", "--black": "#412b34", "--line": "#aa6680", "--gold": "#b28b4f", "--border": "#d9adbd", "--wash-a": "rgba(191,62,112,.20)", "--wash-b": "rgba(112,73,143,.14)", "--fiber": "rgba(103,42,66,.04)" } }; const FONTS = { kai: { css: '"KaiTi","STKaiti","楷体","SimSun","宋体",serif', svg: '"KaiTi","STKaiti","楷体","SimSun","宋体",serif' }, song: { css: 'SimSun,"宋体","NSimSun",serif', svg: 'SimSun,"宋体","NSimSun",serif' }, hei: { css: '"Microsoft YaHei","微软雅黑","SimHei","黑体",sans-serif', svg: '"Microsoft YaHei","微软雅黑","SimHei","黑体",sans-serif' }, fang: { css: '"FangSong","仿宋","STFangsong",serif', svg: '"FangSong","仿宋","STFangsong",serif' }, li: { css: '"LiSu","隶书","STLiti",serif', svg: '"LiSu","隶书","STLiti",serif' } }; let currentFont = FONTS.kai.svg; /* ---------- 多亲缘存储 ---------- */ function normalizeTree(t) { if (!t) return; if (!Array.isArray(t.members)) t.members = []; if (!t.name) t.name = "未命名亲缘"; if (!t.theme) t.theme = "xuan"; if (!t.font) t.font = "kai"; if (t.lineWidth === undefined || t.lineWidth === null) t.lineWidth = 3; if (t.dashForeign === undefined) t.dashForeign = true; if (t.location === undefined) t.location = ""; if (!t.fields) t.fields = { gender: true, rank: true, birth: true, death: true, deceased: true, deceasedBg: false, note: false }; t.members.forEach(m = { if (m.deceased === undefined) m.deceased = false; }); return t; } /* 成员框展示字段配置(姓名始终显示;性别可在框内是否标注) */ function fieldCfg() { const t = currentTree(); const d = { gender: true, rank: true, birth: true, death: true, deceased: true, deceasedBg: false, note: false }; if (!t || !t.fields) return d; return Object.assign({}, d, t.fields); } /* 估算文字宽度(CJK 按字号计,ASCII 约 0.58 倍),用于截断防止超出框 */ function textWidth(str, fs) { let w = 0; for (const ch of str) { w += (ch.codePointAt(0) 255 ? fs : fs * 0.58); } return w; } function fitText(str, maxW, fs) { if (textWidth(str, fs) 255 ? fs : fs * 0.58); if (w + cw maxW - fs * 0.6) return out + "…"; out += ch; w += cw; } return out; } function loadTrees() { try { const t = JSON.parse(localStorage.getItem(STORE)); if (!Array.isArray(t)) return []; t.forEach(normalizeTree); return t; } catch (e) { return []; } } function loadCurrentId() { try { return localStorage.getItem("familytree_current_v1") || ""; } catch (e) { return ""; } } function saveTrees() { try { localStorage.setItem(STORE, JSON.stringify(trees)); localStorage.setItem("familytree_current_v1", currentTreeId || ""); } catch (e) { } } function currentTree() { return trees.find(t = t.id === currentTreeId) || null; } function commitMembers() { const t = currentTree(); if (t) t.members = members; saveTrees(); } function ensureTree() { if (currentTreeId && trees.some(t = t.id === currentTreeId)) return; if (trees.length) { currentTreeId = trees[0].id; return; } trees.push({ id: uid(), name: "我的亲缘", surname: "", theme: "xuan", font: "kai", lineWidth: 3, dashForeign: true, members: [] }); currentTreeId = trees[trees.length - 1].id; } function applyTheme(key) { const t = THEMES[key] || THEMES.xuan; const root = document.documentElement; for (const k in t) root.style.setProperty(k, t[k]); root.dataset.theme = THEMES[key] ? key : "xuan"; } function applyFont(key) { const f = FONTS[key] || FONTS.kai; currentFont = f.svg; document.body.style.fontFamily = f.css; } let trees = loadTrees(); let currentTreeId = loadCurrentId(); ensureTree(); let members = currentTree() ? currentTree().members : []; let mode = "global"; let lastMainMode = "global"; // 记录上次主视图方向(global=横向 / vertical=竖向),分级视图据此保持一致 let famDir = "h"; // 分级视图方向:h=横版 / v=竖版 let focusId = null; let zoom = 1; let autoFit = true; let editingId = null; let dragOffsets = loadOffsets(); let dragState = null; let lastEff = 1; /* ---------- offsets storage ---------- */ function loadOffsets() { try { return JSON.parse(localStorage.getItem("familytree_offsets_v1")) || {}; } catch (e) { return {}; } } function saveOffsets() { try { localStorage.setItem("familytree_offsets_v1", JSON.stringify(dragOffsets)); } catch (e) { } } /* ---------- helpers ---------- */ function byId(id) { return members.find(m = m.id === id); } function childrenOf(id) { return members.filter(m = m.father === id || m.mother === id); } function siblingsOf(m) { if (!m.father && !m.mother) return []; return members.filter(o = o.id !== m.id && ((m.father && o.father === m.father) || (m.mother && o.mother === m.mother))); } function birthVal(m) { if (!m.birth) return 0; const t = Date.parse(m.birth); return isNaN(t) ? 0 : t; } /* 日期点分格式:YYYY-MM-DD → YYYY.M.D(去掉前导零,如 1981.9.23) */ function dateDots(s) { if (!s) return ""; const m = String(s).match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); if (m) return m[1] + "." + parseInt(m[2], 10) + "." + parseInt(m[3], 10); return String(s); } function byBirthAsc(a, b) { return birthVal(a) - birthVal(b); } function byBirthDesc(a, b) { return birthVal(b) - birthVal(a); } function rankNum(m) { if (!m || m.rank === undefined || m.rank === null || m.rank === "") return null; const n = parseInt(m.rank, 10); return isNaN(n) ? null : n; } /* 同辈排序:有排行按排行降序(排行1=老大居最右),无排行回退出生日期降序(年长者居右) */ function bySiblingOrder(a, b) { const ra = rankNum(a), rb = rankNum(b); if (ra !== null && rb !== null) return rb - ra; if (ra !== null) return 1; // 有排行者靠右 if (rb !== null) return -1; return byBirthDesc(a, b); } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } function esc(s) { return (s || "").replace(/[&"]/g, c = ({ "&": "&", "": "", '"': """ }[c])); } function isValidDate(str) { if (!str) return false; const m = str.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!m) return false; const y = +m[1], mo = +m[2], d = +m[3]; if (mo 12 || d 31) return false; if (y 2100) return false; const dt = new Date(y, mo - 1, d); return dt.getFullYear() === y && dt.getMonth() === mo - 1 && dt.getDate() === d; } /* 判断 a 是否为 b 的祖先(用于防止把长辈设为子女造成循环) */ function isAncestor(a, b) { const stack = [b]; const seen = new Set(); while (stack.length) { const cur = stack.pop(); if (!cur || seen.has(cur.id)) continue; seen.add(cur.id); if ((cur.father && cur.father === a.id) || (cur.mother && cur.mother === a.id)) return true; if (cur.father) { const f = byId(cur.father); if (f) stack.push(f); } if (cur.mother) { const mo = byId(cur.mother); if (mo) stack.push(mo); } } return false; } /* 判断 a、b 是否为血亲(同一血缘:自身、祖先、子孙、兄弟姐妹/半血缘) */ function isBloodRelative(a, b) { if (!a || !b || a.id === b.id) return true; if (isAncestor(a, b) || isAncestor(b, a)) return true; function ancestors(m) { const set = new Set(); const st = [m]; while (st.length) { const c = st.pop(); if (c.father) { const f = byId(c.father); if (f && !set.has(f.id)) { set.add(f.id); st.push(f); } } if (c.mother) { const mo = byId(c.mother); if (mo && !set.has(mo.id)) { set.add(mo.id); st.push(mo); } } } return set; } const aa = ancestors(a), ab = ancestors(b); for (const id of aa) if (ab.has(id)) return true; return false; } /* 父母下拉选项:父亲栏只列男性、母亲栏只列女性(含「待录入」占位选项;保存时才真正生成成员,避免中途残留) */ function parentOptions(selected, kind) { const wantM = kind === "fa"; const dummy = wantM ? '待录入(男)' : '待录入(女)'; const list = members.filter(m = m.gender === (wantM ? "M" : "F")); return '(无)' + dummy + list.map(m = `${esc(m.name)}`).join(""); } /* 重建父母下拉选项并保留当前选择与锁定状态 */ function syncParentSelects() { const faSel = document.getElementById("f_fa"), moSel = document.getElementById("f_mo"); if (!faSel || !moSel) return; const fa = faSel.value, mo = moSel.value, faD = faSel.disabled, moD = moSel.disabled; faSel.innerHTML = parentOptions(fa, "fa"); moSel.innerHTML = parentOptions(mo, "mo"); faSel.disabled = faD; moSel.disabled = moD; } /* 选父亲:自动锁定母亲(取其配偶;无配偶则占位「待录入」,保存时才生成);选母亲对称;清空则解锁 */ function autoFixParent(src) { const faSel = document.getElementById("f_fa"), moSel = document.getElementById("f_mo"); if (!faSel || !moSel) return; if (src === "fa") { const fa = faSel.value; if (fa) { if (fa === "__dummy_M__") { moSel.disabled = false; return; } // 用户主动选「待录入(男)」作父,不自动配母 const p = byId(fa); const choices = p ? (p.spouses || []).map(byId).filter(s = s && s.gender === "F") : []; const current = choices.find(s = s.id === moSel.value); if (current) { moSel.disabled = true; } else if (choices.length === 1) { moSel.value = choices[0].id; moSel.disabled = true; } else if (!choices.length) { moSel.value = "__dummy_F__"; moSel.disabled = true; } else { moSel.value = ""; moSel.disabled = false; toast("该父亲有多位配偶,请选择对应母亲"); } syncParentSelects(); } else moSel.disabled = false; } else if (src === "mo") { const mo = moSel.value; if (mo) { if (mo === "__dummy_F__") { faSel.disabled = false; return; } const p = byId(mo); const choices = p ? (p.spouses || []).map(byId).filter(s = s && s.gender === "M") : []; const current = choices.find(s = s.id === faSel.value); if (current) { faSel.disabled = true; } else if (choices.length === 1) { faSel.value = choices[0].id; faSel.disabled = true; } else if (!choices.length) { faSel.value = "__dummy_M__"; faSel.disabled = true; } else { faSel.value = ""; faSel.disabled = false; toast("该母亲有多位配偶,请选择对应父亲"); } syncParentSelects(); } else faSel.disabled = false; } } /* 配偶排序标签:一任 / 二任 / 三任 …… */ function spouseOrderLabel(i) { const cn = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"]; return (cn[i] || ("第" + (i + 1))) + "任"; } /* ---------- 分层组织图布局(自上而下,折线连接,连线不交叉) ---------- */ function buildLayoutWith(cfg) { const W = cfg.W, H = cfg.H, COUPLEGAP = cfg.COUPLEGAP, CHILDGAP = cfg.CHILDGAP, ROWH = cfg.ROWH, MARGIN = cfg.MARGIN; const EXTRAGAP = 12; // 二房等附加配偶与一房夫妻组的间距 const placed = new Set(); const nodes = []; let maxDepth = 0; const pos = {}; /* 节点 = 一个「主位成员 + 其配偶们」;childGroups 按实际父母配对分组,避免异父母子女共用一条连线 */ function makeNode(primary, spouse, extras) { const n = { primary: primary || null, husband: null, wife: null, extras: extras || [], childGroups: [], x: 0, width: 0, cx: 0, depth: 0 }; if (primary) { if (primary.gender === "M") { n.husband = primary; n.wife = spouse || null; } else { n.wife = primary; n.husband = spouse || null; } } else if (spouse) { if (spouse.gender === "M") n.husband = spouse; else n.wife = spouse; } nodes.push(n); return n; } function childSpousesOf(child) { return (child.spouses || []).map(byId).filter(s = s && s.id !== child.id && !placed.has(s.id)); } // 精确匹配父/母对的子女(双方 id 一致才归为一组) function kidsOfPair(a, b) { const aid = a ? a.id : null, bid = b ? b.id : null; return members.filter(c = (c.father === aid && c.mother === bid) || (c.father === bid && c.mother === aid)).sort(bySiblingOrder); } function build(node) { const P = node.primary; const spouse = P ? ((P.gender === "M") ? node.wife : node.husband) : null; const partners = []; if (spouse) partners.push(spouse); (node.extras || []).forEach(e = partners.push(e)); const groups = []; if (P) { const claimed = new Set(); partners.forEach(pt = { const kids = kidsOfPair(P, pt); kids.forEach(k = claimed.add(k.id)); if (kids.length) groups.push({ parents: [P, pt], kids }); }); // 单亲子女或未匹配到任何配偶的子女:归入一房组(无配偶则单独成组),避免被遗漏 const leftover = childrenOf(P.id).filter(c = !claimed.has(c.id)).sort(bySiblingOrder); if (leftover.length) { if (groups.length) groups[0].kids = groups[0].kids.concat(leftover).sort(bySiblingOrder); else groups.push({ parents: [P], kids: leftover }); } } else { const ps = [node.husband, node.wife].filter(Boolean); if (ps.length) { const kids = kidsOfPair(ps[0], ps[1] || null); if (kids.length) groups.push({ parents: ps, kids }); } } groups.forEach(g = { g.children = g.kids.map(child = { const spouses = childSpousesOf(child); const sp = spouses[0]; // 一房 const extras = spouses.slice(1); // 二房及以后 const nd = makeNode(child, sp, extras); if (nd.husband) placed.add(nd.husband.id); if (nd.wife) placed.add(nd.wife.id); extras.forEach(e = placed.add(e.id)); return nd; }); g.children.forEach(build); }); node.childGroups = groups; } function coupleWidth(n) { let cw = (n.wife ? W : 0) + (n.husband ? W : 0) + ((n.wife && n.husband) ? COUPLEGAP : 0); const ex = (n.extras || []).length; if (ex) cw += ex * (W + EXTRAGAP); // 二房等排在左侧 return cw; } function childrenTotalWidth(groups) { let t = 0; groups.forEach((g, i) = { if (i 0) t += CHILDGAP; // 组间间隔 t += g.children.reduce((a, c) = a + c.width, 0) + CHILDGAP * (g.children.length - 1); }); return t; } function measure(n) { // 先递归测量所有子女,保证子节点 width 已就绪,再汇总本节点宽度 const groups = n.childGroups || []; groups.forEach(g = g.children.forEach(c = measure(c))); const cw = coupleWidth(n); if (!groups.length) { n.width = cw; return cw; } n.width = Math.max(cw, childrenTotalWidth(groups)); return n.width; } function place(n, x, depth) { n.depth = depth; if (depth maxDepth) maxDepth = depth; const ccx = x + n.width / 2; n.cx = ccx; const cw = coupleWidth(n); let startX = ccx - cw / 2; // 二房及以后(附加配偶)排在最左 (n.extras || []).forEach(e = { pos[e.id] = { x: startX, y: MARGIN + depth * ROWH, cx: startX + W / 2, cy: MARGIN + depth * ROWH + H / 2 }; startX += W + EXTRAGAP; }); const order = []; if (n.wife) order.push(n.wife); // 妻在左 if (n.husband) order.push(n.husband); // 夫在右 order.forEach(p = { pos[p.id] = { x: startX, y: MARGIN + depth * ROWH, cx: startX + W / 2, cy: MARGIN + depth * ROWH + H / 2 }; startX += W; if (n.wife && n.husband) startX += COUPLEGAP; }); const groups = n.childGroups || []; if (groups.length) { let cx0 = ccx - childrenTotalWidth(groups) / 2; groups.forEach((g, gi) = { if (gi 0) cx0 += CHILDGAP; g.children.forEach(c = { place(c, cx0, depth + 1); cx0 += c.width + CHILDGAP; }); }); } } const roots = members.filter(m = !m.father && !m.mother); const rootNodes = []; roots.forEach(r = { if (placed.has(r.id)) return; // 已被前面的根作为配偶纳入,避免重复成树 const spouses = (r.spouses || []).map(byId).filter(s = s && !placed.has(s.id)); const sp = spouses[0]; const extras = spouses.slice(1); const n = makeNode(r, sp, extras); if (n.husband) placed.add(n.husband.id); if (n.wife) placed.add(n.wife.id); extras.forEach(e = placed.add(e.id)); build(n); rootNodes.push(n); }); members.filter(m = !placed.has(m.id)).forEach(m = { placed.add(m.id); const n = makeNode(m, null, []); build(n); rootNodes.push(n); }); let cursor = MARGIN; rootNodes.forEach((rc, i) = { if (i 0) cursor += CHILDGAP; measure(rc); place(rc, cursor, 0); cursor += rc.width; }); const naturalWidth = cursor + MARGIN; const width = Math.max(420, naturalWidth); if (width naturalWidth) { const dx = (width - naturalWidth) / 2; Object.values(pos).forEach(p = { p.x += dx; p.cx += dx; }); } const height = MARGIN * 2 + (maxDepth + 1) * ROWH; return { pos, nodes, width, height }; } function buildLayout() { return buildLayoutWith(HCFG); } /* ---------- 字号(基准 16,下拉档位 12–22,+/− 逐级调) ---------- */ let textSize = 16; const MIN_TEXT = 10, MAX_TEXT = 72; function clampText(v) { v = parseInt(v, 10); if (isNaN(v)) return 16; return Math.max(MIN_TEXT, Math.min(MAX_TEXT, v)); } function loadTextSize() { try { return clampText(parseInt(localStorage.getItem("familytree_textsize_v1"), 10)); } catch (e) { return 16; } } function saveTextSize() { try { localStorage.setItem("familytree_textsize_v1", String(textSize)); } catch (e) { } } function applyTextSize() { const inp = document.getElementById("fontSizeInput"); if (inp) inp.value = String(textSize); renderAll(); } /* ---------- svg element ---------- */ function el(tag, attrs) { const e = document.createElementNS(SVGNS, tag); for (const k in attrs) { if (k === "font-size") e.setAttribute(k, (parseFloat(attrs[k]) * textSize / 16).toFixed(1)); else e.setAttribute(k, attrs[k]); } return e; } function relLineStyle() { const t = currentTree() || { lineWidth: 3 }; return { "stroke-width": (t.lineWidth || 3) }; } function spouseLine(gL, a, b) { const y = (a.cy + b.cy) / 2; const style = Object.assign({ stroke: "var(--red)" }, relLineStyle()); gL.appendChild(el("line", Object.assign({ x1: a.x + W, y1: y - 5, x2: b.x, y2: y - 5 }, style))); gL.appendChild(el("line", Object.assign({ x1: a.x + W, y1: y + 5, x2: b.x, y2: y + 5 }, style))); } function busConnect(gL, parentCx, parentBottomY, childCxs, childTopY) { const busY = (parentBottomY + childTopY) / 2; const minx = Math.min(...childCxs), maxx = Math.max(...childCxs); let d = `M ${parentCx} ${parentBottomY} L ${parentCx} ${busY} M ${minx} ${busY} L ${maxx} ${busY}`; childCxs.forEach(x = { d += ` M ${x} ${busY} L ${x} ${childTopY}`; }); gL.appendChild(el("path", Object.assign({ d, fill: "none", stroke: "var(--line)" }, relLineStyle()))); } /* ---------- render: global ---------- */ function effZoom(lay, wrap, minEff = 0.5) { const availW = (wrap.clientWidth || 800) - 16; const ch = wrap.clientHeight || 0; const availH = (ch 80 ? ch : window.innerHeight - 260) - 16; if (autoFit) { let f = Math.min(availW / lay.width, availH / lay.height, 1); if (!isFinite(f) || f = 0; i--) { if (ly { e.stopPropagation(); const o = dragOffsets[m.id] || { dx: 0, dy: 0 }; dragState = { id: m.id, sx: e.clientX, sy: e.clientY, baseDx: o.dx, baseDy: o.dy, moved: false }; window.addEventListener("pointermove", onDragMove); window.addEventListener("pointerup", onDragUp); }); } else { g.addEventListener("click", () = openDetail(m.id)); } gB.appendChild(g); } function onDragMove(e) { if (!dragState) return; const dx = (e.clientX - dragState.sx) / lastEff + dragState.baseDx; const dy = (e.clientY - dragState.sy) / lastEff + dragState.baseDy; dragOffsets[dragState.id] = { dx, dy }; if (Math.abs(e.clientX - dragState.sx) 3 || Math.abs(e.clientY - dragState.sy) 3) dragState.moved = true; renderGlobal(); } function onDragUp() { if (!dragState) return; const wasMoved = dragState.moved, id = dragState.id; dragState = null; window.removeEventListener("pointermove", onDragMove); window.removeEventListener("pointerup", onDragUp); if (wasMoved) { saveOffsets(); } else { const m = byId(id); if (m) openDetail(m.id); } } /* 画布平移:放大后拖动空白处查看亲缘各部位(鼠标;触摸用原生滚动) */ let panState = null; function currentScrollWrap() { return document.getElementById(mode === "family" ? "familyWrap" : "globalWrap"); } function bindPan(wrap) { if (!wrap) return; wrap.addEventListener("pointerdown", e = { if (e.pointerType !== "mouse" || e.button !== 0) return; panState = { sx: e.clientX, sy: e.clientY, sl: wrap.scrollLeft, st: wrap.scrollTop }; wrap.style.cursor = "grabbing"; window.addEventListener("pointermove", onPanMove); window.addEventListener("pointerup", onPanUp); }); } function onPanMove(e) { if (!panState) return; const wrap = currentScrollWrap(); if (!wrap) return; wrap.scrollLeft = panState.sl - (e.clientX - panState.sx); wrap.scrollTop = panState.st - (e.clientY - panState.sy); } function onPanUp() { panState = null; const wrap = currentScrollWrap(); if (wrap) wrap.style.cursor = ""; window.removeEventListener("pointermove", onPanMove); window.removeEventListener("pointerup", onPanUp); } /* 分割线:桌面左右拖动调整录入栏宽度,移动端上下拖动调整高度(记忆) */ let splitState = null; function loadSplitW() { try { const v = parseInt(localStorage.getItem("familytree_splitwidth_v1"), 10); return (v = 240 && v = 160 && v { if (e.pointerType !== "mouse" && e.pointerType !== "touch") return; e.preventDefault(); const ep = document.getElementById("entryPanel"); splitState = { sx: e.clientX, sy: e.clientY, w: ep.offsetWidth || 360, h: ep.offsetHeight || 280, row: isRowSplit() }; window.addEventListener("pointermove", onSplitMove); window.addEventListener("pointerup", onSplitUp); }); } function onSplitMove(e) { if (!splitState) return; const ep = document.getElementById("entryPanel"); if (splitState.row) { const maxW = Math.max(260, window.innerWidth - 380); const w = Math.max(240, Math.min(maxW, splitState.w + (e.clientX - splitState.sx))); ep.style.width = w + "px"; } else { const maxH = Math.max(200, window.innerHeight - 300); const h = Math.max(160, Math.min(maxH, splitState.h - (e.clientY - splitState.sy))); ep.style.height = h + "px"; } } function onSplitUp() { if (!splitState) return; saveSplit(); splitState = null; window.removeEventListener("pointermove", onSplitMove); window.removeEventListener("pointerup", onSplitUp); renderAll(); // 按新的可用高度/宽度重算展示区缩放 } function renderGlobal() { const lay = buildLayout(); const wrap = document.getElementById("globalWrap"); const eff = effZoom(lay, wrap); lastEff = eff; // 应用手动拖动偏移(限制在图框范围内) members.forEach(m = { const p = lay.pos[m.id]; if (p && dragOffsets[m.id]) { const o = dragOffsets[m.id]; p.x = Math.max(0, Math.min(lay.width - W, p.x + o.dx)); p.y = Math.max(0, Math.min(lay.height - H, p.y + o.dy)); p.cx = p.x + W / 2; p.cy = p.y + H / 2; } }); const svg = document.getElementById("globalSvg"); svg.setAttribute("viewBox", `0 0 ${lay.width} ${lay.height}`); svg.setAttribute("width", lay.width * eff); svg.setAttribute("height", lay.height * eff); while (svg.firstChild) svg.removeChild(svg.firstChild); const gL = el("g", {}); svg.appendChild(gL); const gB = el("g", {}); svg.appendChild(gB); // 夫妻双线(虚线),使用实际(含拖动偏移)坐标 lay.nodes.forEach(n = { if (n.husband && n.wife && (n.husband.spouses || []).includes(n.wife.id)) { const a = lay.pos[n.wife.id], b = lay.pos[n.husband.id]; if (a && b) spouseLine(gL, a, b); } }); // 父母→子女 折线(不交叉),按父母配对分组连线,异父母子女各自独立 lay.nodes.forEach(n = { (n.childGroups || []).forEach(g = { if (!g.children.length) return; const pp = g.parents.filter(p = p && lay.pos[p.id]).map(p = lay.pos[p.id]); if (!pp.length) return; const parentCx = pp.reduce((s, p) = s + p.cx, 0) / pp.length; const parentBottomY = Math.max(...pp.map(p = p.y + H)); const childPoss = g.children.map(c = [c.husband, c.wife].filter(p = p && lay.pos[p.id]).map(p = lay.pos[p.id])).filter(arr = arr.length); if (!childPoss.length) return; const childCxs = childPoss.map(arr = arr.reduce((s, p) = s + p.cx, 0) / arr.length); const childTopY = Math.min(...childPoss.flat().map(p = p.y)); busConnect(gL, parentCx, parentBottomY, childCxs, childTopY); }); }); // 成员框(全局图可拖动) members.forEach(m = { const p = lay.pos[m.id]; if (p) drawBox(gB, m, p, false, true); }); if (!members.length) { const t = el("text", { x: 30, y: 44, "font-size": 18, fill: "var(--ink-soft)", "font-family": currentFont }); t.textContent = "暂无成员,请点击「录入成员」开始创建亲缘。"; svg.appendChild(t); } addTitleDecor(svg, lay.width, lay.height, eff); } /* ---------- render: vertical(竖条框 + 文字竖排,布局与全局一致) ---------- */ /* 夫妻竖双线(竖条框之间) */ function spouseLineV(gL, a, b) { const xm = (a.x + VW + b.x) / 2; const d = Object.assign({ stroke: "var(--red)" }, relLineStyle()); gL.appendChild(el("line", Object.assign({ x1: xm - 3, y1: a.y + 14, x2: xm - 3, y2: a.y + VH - 14 }, d))); gL.appendChild(el("line", Object.assign({ x1: xm + 3, y1: a.y + 14, x2: xm + 3, y2: a.y + VH - 14 }, d))); } function drawBoxV(gB, m, p, highlight) { const g = el("g", {}); const f = fieldCfg(); const t = currentTree() || { surname: "", dashForeign: true }; const surname = t.surname || ""; const sameSurname = surname && m.name && m.name.indexOf(surname) === 0; const stroke = m.gender === "F" ? "var(--red)" : "var(--black)"; const fill = "var(--paper2)"; const rectAttrs = { x: p.x, y: p.y, width: VW, height: VH, rx: 7, fill, stroke, "stroke-width": 3 }; if (!sameSurname && surname && t.dashForeign) rectAttrs["stroke-dasharray"] = "6 4"; g.appendChild(el("rect", rectAttrs)); // 「卒」标注(由展示设置「去世」开关控制,右下角,不与底部备注行重叠) if (f.deceasedBg && m.deceased) { const dy2 = (f.note && m.note) ? (p.y + VH - 18) : (p.y + VH - 6); const dTag = el("text", { x: p.x + VW - 6, y: dy2, "text-anchor": "end", "font-size": 9, fill: "var(--ink-soft)", "font-family": currentFont }); dTag.textContent = "卒"; g.appendChild(dTag); } // 姓名竖排(居中,字号/行距按字数自适应) const chars = (m.name || "").split(""); const n = chars.length; const fs = Math.max(10, Math.min(16, n { const ts = document.createElementNS(SVGNS, "tspan"); ts.setAttribute("x", p.cx); ts.setAttribute("dy", i === 0 ? 0 : dy); ts.textContent = ch; t1.appendChild(ts); }); g.appendChild(t1); // 排行:姓名正下方居中 if (f.rank && m.rank !== "" && m.rank != null) { const rankY = p.y + 42 + (n - 1) * dy + 16; const rTag = el("text", { x: p.cx, y: rankY, "text-anchor": "middle", "font-size": 11, fill: "var(--gold)", "font-family": currentFont }); rTag.textContent = "行" + m.rank; g.appendChild(rTag); } // 生卒日期:姓名左侧双竖列——卒列在最左、生列紧邻其右(均在姓名左侧、互不重叠,行距按框高自适应) const vFs = 8, colGap = 2; const vTop = p.y + 24, vBot = p.y + VH - 8; // 顶部留白、底部预留备注行 const cols = []; if (f.death && m.death) cols.push({ txt: "卒" + dateDots(m.death), x: p.x + 5 }); // 左列:卒 if (f.birth && m.birth) cols.push({ txt: "生" + dateDots(m.birth), x: p.x + 5 + (vFs + colGap) }); // 右列:生(仍在姓名左侧) if (cols.length) { const maxLen = cols.reduce((m2, c) = Math.max(m2, c.txt.length), 0); let vDy = Math.round((vFs + 1) * textSize / 16); if ((maxLen - 1) * vDy (vBot - vTop)) vDy = Math.max(4, Math.floor((vBot - vTop) / (maxLen - 1))); cols.forEach(c = { const t = el("text", { x: c.x, y: vTop, "text-anchor": "start", "font-size": vFs, fill: "var(--ink-soft)", "font-family": currentFont }); c.txt.split("").forEach((ch, i) = { const ts = document.createElementNS(SVGNS, "tspan"); ts.setAttribute("x", c.x); ts.setAttribute("dy", i === 0 ? 0 : vDy); ts.textContent = ch; t.appendChild(ts); }); g.appendChild(t); }); } // 备注:底部一行(水平、截断),小字宋体、不加「备注」前缀、不超框 if (f.note && m.note) { const nt = el("text", { x: p.cx, y: p.y + VH - 6, "text-anchor": "middle", "font-size": 8, fill: "var(--ink-soft)", "font-family": "SimSun, \"宋体\", serif" }); nt.textContent = fitText(m.note, VW - 8, 8); g.appendChild(nt); } if (highlight) { g.appendChild(el("rect", { x: p.x - 3, y: p.y - 3, width: VW + 6, height: VH + 6, rx: 9, fill: "none", stroke: "var(--gold)", "stroke-width": 2, "stroke-dasharray": "5 4" })); } g.addEventListener("click", () = openDetail(m.id)); gB.appendChild(g); } function renderVertical() { const lay = buildLayoutWith(VCFG); const Wv = VCFG.W, Hv = VCFG.H; const wrap = document.getElementById("globalWrap"); const eff = effZoom(lay, wrap); lastEff = eff; const svg = document.getElementById("globalSvg"); svg.setAttribute("viewBox", `0 0 ${lay.width} ${lay.height}`); svg.setAttribute("width", lay.width * eff); svg.setAttribute("height", lay.height * eff); while (svg.firstChild) svg.removeChild(svg.firstChild); const gL = el("g", {}); svg.appendChild(gL); const gB = el("g", {}); svg.appendChild(gB); // 夫妻双线 lay.nodes.forEach(n = { if (n.husband && n.wife && (n.husband.spouses || []).includes(n.wife.id)) { const a = lay.pos[n.wife.id], b = lay.pos[n.husband.id]; if (a && b) spouseLineV(gL, a, b); } }); // 父母→子女 折线(不交叉),按父母配对分组连线 lay.nodes.forEach(n = { (n.childGroups || []).forEach(g = { if (!g.children.length) return; const pp = g.parents.filter(p = p && lay.pos[p.id]).map(p = lay.pos[p.id]); if (!pp.length) return; const parentCx = pp.reduce((s, p) = s + p.cx, 0) / pp.length; const parentBottomY = Math.max(...pp.map(p = p.y + Hv)); const childPoss = g.children.map(c = [c.husband, c.wife].filter(p = p && lay.pos[p.id]).map(p = lay.pos[p.id])).filter(arr = arr.length); if (!childPoss.length) return; const childCxs = childPoss.map(arr = arr.reduce((s, p) = s + p.cx, 0) / arr.length); const childTopY = Math.min(...childPoss.flat().map(p = p.y)); busConnect(gL, parentCx, parentBottomY, childCxs, childTopY); }); }); // 成员框(竖条 + 文字竖排) members.forEach(m = { const p = lay.pos[m.id]; if (p) drawBoxV(gB, m, p, false); }); if (!members.length) { const t = el("text", { x: 30, y: 44, "font-size": 18, fill: "var(--ink-soft)", "font-family": currentFont }); t.textContent = "暂无成员,请点击「录入成员」开始创建亲缘。"; svg.appendChild(t); } addTitleDecor(svg, lay.width, lay.height, eff); } /* ---------- render: family(分级) ---------- */ function renderFamily() { const wrap = document.getElementById("familyView"); wrap.innerHTML = ""; if (!members.length) { wrap.innerHTML = '暂无成员。 '; return; } if (!focusId || !byId(focusId)) focusId = members[0].id; const sel = document.getElementById("focusSelect"); if (sel.value !== focusId) sel.value = focusId; const C = byId(focusId); const F = byId(C.father), M = byId(C.mother); const spouses = (C.spouses || []).map(byId).filter(Boolean); const peers = [C, ...siblingsOf(C)].sort(bySiblingOrder); const kids = childrenOf(C.id).slice().sort(bySiblingOrder); const isV = (famDir === "v"); const fh = document.getElementById("famHorizontal"), fv = document.getElementById("famVertical"); if (fh) fh.classList.toggle("active", !isV); if (fv) fv.classList.toggle("active", isV); const cfg = isV ? VCFG : HCFG; const BW = cfg.W, BH = cfg.H, GAP = cfg.CHILDGAP, COUP = cfg.COUPLEGAP, ROW = cfg.ROWH; const pos = {}; const rowY = { parents: MARGIN, peers: MARGIN + ROW, kids: MARGIN + 2 * ROW }; // 每个同辈占一个单元;焦点成员与配偶组成一个单元。单元内部始终妻左夫右。 const parentUnits = (F || M) ? [[M, F].filter(Boolean)] : []; const peerUnits = peers.map(p = p.id === C.id ? [...spouses, C].sort((a, b) = (a.gender === "F" ? 0 : 1) - (b.gender === "F" ? 0 : 1)) : [p]); const kidUnits = kids.map(k = [k]); function unitWidth(unit) { return unit.length * BW + Math.max(0, unit.length - 1) * COUP; } function rowWidth(units) { return units.reduce((sum, u) = sum + unitWidth(u), 0) + Math.max(0, units.length - 1) * GAP; } const parentW = rowWidth(parentUnits), peerW = rowWidth(peerUnits), kidW = rowWidth(kidUnits); const height = MARGIN * 2 + 3 * ROW; function placeRowAt(units, y, startX) { let x = startX; units.forEach(unit = { unit.forEach((m, i) = { pos[m.id] = { x, y, cx: x + BW / 2, cy: y + BH / 2 }; x += BW; if (i unit.some(m = m.id === C.id)) || [C]; const focusLeft = Math.min(...focusUnit.map(m = pos[m.id].x)); const focusRight = Math.max(...focusUnit.map(m = pos[m.id].x + BW)); const focusCenter = (focusLeft + focusRight) / 2; const parentStart = peerW / 2 - parentW / 2; const kidStart = focusCenter - kidW / 2; placeRowAt(parentUnits, rowY.parents, parentStart); placeRowAt(kidUnits, rowY.kids, kidStart); // 对齐后按三行的实际边界扩展并整体平移,保证左右留白一致且不越出画框。 const starts = [0]; const ends = [peerW]; if (parentUnits.length) { starts.push(parentStart); ends.push(parentStart + parentW); } if (kidUnits.length) { starts.push(kidStart); ends.push(kidStart + kidW); } const minX = Math.min(...starts), maxX = Math.max(...ends); const span = Math.max(BW, maxX - minX); const width = Math.max(420, MARGIN * 2 + span); const shift = (width - span) / 2 - minX; Object.values(pos).forEach(p = { p.x += shift; p.cx += shift; }); const scroller = document.getElementById("familyWrap"); const eff = effZoom({ width, height: height + HEADER_H }, scroller, 0.12); const svg = el("svg", { width: width * eff, height: (height + HEADER_H) * eff }); svg.setAttribute("viewBox", `0 0 ${width} ${height}`); svg.style.display = "block"; svg.style.margin = "0 auto"; const gL = el("g", {}); svg.appendChild(gL); const gB = el("g", {}); svg.appendChild(gB); if (F && M && (F.spouses || []).includes(M.id)) { if (isV) spouseLineV(gL, pos[M.id], pos[F.id]); else spouseLine(gL, pos[M.id], pos[F.id]); } if ((F || M) && peers.length) { const parents = [F, M].filter(Boolean).map(p = pos[p.id]); const parentCx = parents.reduce((sum, p) = sum + p.cx, 0) / parents.length; busConnect(gL, parentCx, rowY.parents + BH, peers.map(p = pos[p.id].cx), rowY.peers); } spouses.forEach(s = { const wife = C.gender === "F" ? C : s, husband = C.gender === "M" ? C : s; if (isV) spouseLineV(gL, pos[wife.id], pos[husband.id]); else spouseLine(gL, pos[wife.id], pos[husband.id]); }); if (kids.length) { const groups = new Map(); kids.forEach(k = { const otherId = C.gender === "M" ? k.mother : k.father; const key = otherId && spouses.some(s = s.id === otherId) ? otherId : ""; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(k); }); groups.forEach((group, otherId) = { const other = otherId ? byId(otherId) : null; const parentCx = other ? (pos[C.id].cx + pos[other.id].cx) / 2 : pos[C.id].cx; // 有完整父母记录时从夫妻双线中心向下引出;单亲子女才从成员框底部引出。 const parentStartY = other ? pos[C.id].cy : (rowY.peers + BH); busConnect(gL, parentCx, parentStartY, group.map(k = pos[k.id].cx), rowY.kids); }); } Object.keys(pos).forEach(id = { const m = byId(id), p = pos[id]; if (isV) drawBoxV(gB, m, p, m.id === C.id); else drawBox(gB, m, p, m.id === C.id, false); }); addTitleDecor(svg, width, height, eff); wrap.appendChild(svg); } /* ---------- detail modal ---------- */ function relChip(m) { if (!m) return ""; const cls = m.gender === "F" ? "f" : "m"; return `${esc(m.name)}(${m.gender === "F" ? "女" : "男"})`; } function relChipWith(m, prefix) { if (!m) return ""; const cls = m.gender === "F" ? "f" : "m"; const pre = prefix ? `${prefix}` : ""; return `${pre}${esc(m.name)}(${m.gender === "F" ? "女" : "男"})`; } function openDetail(id) { const m = byId(id); if (!m) return; const F = byId(m.father), M = byId(m.mother); const sp = (m.spouses || []).map(byId).filter(Boolean); const sibs = siblingsOf(m); const ch = childrenOf(m.id); const meta = [`性别:${m.gender === "F" ? "女" : "男"}`]; if (m.birth) meta.push(`生辰:${dateDots(m.birth)}`); if (m.death) meta.push(`卒日:${dateDots(m.death)}`); if (m.rank !== "" && m.rank != null) meta.push(`排行:${m.rank}`); if (m.note) meta.push(`备注:${m.note}`); const groups = [ ["父母", [F, M].filter(Boolean)], ["配偶", sp], ["兄弟姊妹", sibs], ["子女", ch] ]; let html = `${esc(m.name)}${meta.map(esc).join(" · ")} 状态:${m.deceased ? "卒" : "在世"} 是否去世`; groups.forEach(([title, arr]) = { html += `${title}`; if (arr.length) { if (title === "配偶") html += arr.map((s, i) = relChipWith(s, spouseOrderLabel(i))).join(""); else html += arr.map(relChip).join(""); } else html += '无记录'; html += ``; }); html += `编辑 删除 在分级视图中查看 关闭 `; const modal = document.getElementById("modal"); modal.innerHTML = html; modal.querySelectorAll(".rel-chip").forEach(c = c.addEventListener("click", () = { const tid = c.getAttribute("data-id"); focusId = tid; setMode("family"); closeModal(); })); document.getElementById("mDeceased").addEventListener("change", e = { m.deceased = e.target.checked; document.getElementById("mStatus").textContent = m.deceased ? "卒" : "在世"; commitMembers(); renderAll(); }); document.getElementById("mEdit").addEventListener("click", () = { closeModal(); editMember(id); }); document.getElementById("mDel").addEventListener("click", () = { closeModal(); delMember(id); }); document.getElementById("mFocus").addEventListener("click", () = { focusId = id; setMode("family"); closeModal(); }); document.getElementById("mClose").addEventListener("click", closeModal); document.getElementById("mask").classList.add("show"); } function closeModal() { document.getElementById("mask").classList.remove("show"); } /* ---------- 亲缘列表(顶部下拉) ---------- */ function renderTreeSelect() { const sel = document.getElementById("treeSelect"); sel.innerHTML = trees.map(t = `${esc(t.name || "未命名亲缘")}(${t.members.length}人)`).join(""); if (currentTreeId) sel.value = currentTreeId; } function treeTitleLines() { const t = currentTree(); const s = t && t.surname ? t.surname.trim() : ""; let loc = (t && t.location) ? t.location.trim() : ""; let name = (t && t.name && t.name !== "我的亲缘") ? t.name.trim() : ""; // 祖籍为空时,若亲缘名称含地址要素(省/市/县/区/镇/乡/村等),整名视为地址 if (!loc && name && /[省市区县镇乡村]/.test(name)) { loc = name; name = ""; } let main = s ? (s + "氏亲缘") : (name || "亲缘"); // 若地址以「姓氏+氏亲缘」结尾,去掉该后缀,避免与亲缘名重复 if (loc && main && loc.endsWith(main)) loc = loc.slice(0, loc.length - main.length).trim(); return loc ? [loc, main] : [main]; } function updateBannerTitle() { const lines = treeTitleLines(); const locEl = document.getElementById("bannerLoc"); const el = document.getElementById("bannerTitle"); if (locEl) locEl.textContent = lines.length 1 ? lines[0] : ""; if (el) el.textContent = lines[lines.length - 1]; } function applyTreeViewState() { const t = currentTree(); document.getElementById("locationInput").value = t ? (t.location || "") : ""; document.getElementById("surnameInput").value = t ? t.surname : ""; document.getElementById("themeSelect").value = t ? t.theme : "xuan"; document.getElementById("fontSelect").value = t ? t.font : "kai"; document.getElementById("lineWidthSelect").value = t ? (t.lineWidth !== undefined ? t.lineWidth : 3) : 3; document.getElementById("dashForeign").checked = t ? t.dashForeign !== false : true; const fc = fieldCfg(); document.querySelectorAll(".fld").forEach(cb = { cb.checked = fc[cb.value] !== false; }); applyTheme(t ? t.theme : "xuan"); applyFont(t ? t.font : "kai"); updateBannerTitle(); } /* ---------- member list (entry):仅显示 姓名 / 性别 / 操作 ---------- */ function renderMemberList() { const box = document.getElementById("memberList"); if (!members.length) { box.innerHTML = ""; return; } let rows = members.map(m = { return ` ${esc(m.name)} ${m.gender === "F" ? "女" : "男"} 编辑 删除 `; }).join(""); box.innerHTML = `姓名性别操作${rows}`; box.querySelectorAll("[data-edit]").forEach(b = b.addEventListener("click", () = editMember(b.getAttribute("data-edit")))); box.querySelectorAll("[data-del]").forEach(b = b.addEventListener("click", () = delMember(b.getAttribute("data-del")))); } /* ---------- entry form ---------- */ function memberOptions(selected, gender) { let list = members; if (gender) list = members.filter(m = m.gender === gender); return list.map(m = `${esc(m.name)}(${m.gender === "F" ? "女" : "男"})`).join(""); } function refreshRelBox() { const rel = document.getElementById("f_rel").value; const box = document.getElementById("f_relBox"); if (rel === "none") { box.innerHTML = ""; return; } if (rel === "spouse") { // 配偶仅能选择异性:当前为男性则列女性、为女性则列男性 const myGender = document.querySelector('input[name=f_gender]:checked').value; const wantGender = (myGender === "M") ? "F" : "M"; box.innerHTML = `配偶 *${memberOptions("", wantGender)} 夫妻须一男一女且非血亲;已有配偶者再次添加将记为二任、三任……(保留原配偶,不替换)。`; } else if (rel === "child") { box.innerHTML = `父亲${parentOptions("", "fa")} 母亲${parentOptions("", "mo")} 选父亲即自动填入其配偶为母亲并锁定;若该父辈未录配偶,自动占位「待录入」(保存时生成)。选母亲对称。清空一方即可解锁改选。`; document.getElementById("f_fa").addEventListener("change", () = autoFixParent("fa")); document.getElementById("f_mo").addEventListener("change", () = autoFixParent("mo")); } else if (rel === "parent") { box.innerHTML = `子女 *${memberOptions("")}`; } } /* 解除某成员的全部亲缘关系(用于「改为独立」) */ function unlinkRelations(m) { members.forEach(o = { if (o.father === m.id) o.father = null; if (o.mother === m.id) o.mother = null; }); m.father = null; m.mother = null; (m.spouses || []).forEach(sid = { const s = byId(sid); if (s && s.spouses) s.spouses = s.spouses.filter(x = x !== m.id); }); m.spouses = []; } /* 按表单建立关系;返回 null 成功,或错误提示字符串 */ function applyRelation(m) { const rel = document.getElementById("f_rel").value; if (rel === "none") return null; if (rel === "child") { const faRaw = document.getElementById("f_fa").value; const moRaw = document.getElementById("f_mo").value; let fa = faRaw, mo = moRaw; // 解析「待录入」占位:保存时才真正生成成员并与其配偶互挂 if (fa === "__dummy_M__" || mo === "__dummy_F__") { const otherId = (fa === "__dummy_M__") ? mo : fa; const other = byId(otherId); if (!other) return "请先选择一位真实父辈"; const dgender = (fa === "__dummy_M__") ? "M" : "F"; const d = { id: uid(), name: "待录入", gender: dgender, father: null, mother: null, spouses: [other.id], birth: "", death: "", rank: "", note: "系统自动生成,请编辑完善" }; members.push(d); other.spouses = (other.spouses || []); if (!other.spouses.includes(d.id)) other.spouses.push(d.id); if (fa === "__dummy_M__") fa = d.id; else mo = d.id; } if (!fa && !mo) return "请至少选择一位父母"; if (fa && fa === mo) return "父亲与母亲不能是同一人"; if ((fa && fa === m.id) || (mo && mo === m.id)) return "不能将自己设为自己的父母"; const faP = fa ? byId(fa) : null, moP = mo ? byId(mo) : null; if (faP && faP.gender !== "M") return "所选「父亲」应为男性,请检查"; if (moP && moP.gender !== "F") return "所选「母亲」应为女性,请检查"; // 严格乱伦/血缘循环:父母不能是本人的血亲(兄弟姐妹、祖辈、子孙等),也不能是本人子孙。 // 注意:本人「既有」的父/母本就是血亲(直系祖先),保存时放行,仅对新选定的父/母做校验。 if (faP && fa !== m.father && isBloodRelative(faP, m)) return "所选父亲与本人存在血亲关系(兄弟姐妹、祖辈或子孙),不能作为父母"; if (moP && mo !== m.mother && isBloodRelative(moP, m)) return "所选母亲与本人存在血亲关系(兄弟姐妹、祖辈或子孙),不能作为父母"; if (faP && isAncestor(m, faP)) return "所选父亲是本人的子孙,会造成血缘循环"; if (moP && isAncestor(m, moP)) return "所选母亲是本人的子孙,会造成血缘循环"; if (faP && moP && isBloodRelative(faP, moP)) return "父母双方为近亲(血亲),关系不当,请检查"; const parents = [faP, moP].filter(Boolean); for (const p of parents) { if (p.birth && m.birth && Date.parse(m.birth) Date.parse(c.birth)) return `父母(${m.name},${m.birth})不能比子女(${c.name},${c.birth})年轻`; if (m.gender === "M") { if (c.father) return "该成员已有父亲"; } else { if (c.mother) return "该成员已有母亲"; } } // 只调整目标子女的挂接,不影响本成员的其他子女;若 c 曾误挂到另一性别槽则先清理 if (c.father === m.id) c.father = null; if (c.mother === m.id) c.mother = null; if (m.gender === "M") c.father = m.id; else c.mother = m.id; return null; } return null; } /* 修改日期后重新核对已经存在的上下代关系,避免编辑绕过出生先后校验。 */ function validateChronology(m) { if (!m || !m.birth) return null; const parents = [byId(m.father), byId(m.mother)].filter(Boolean); for (const p of parents) { if (p.birth && Date.parse(m.birth) 99)) { toast("排行须为 1–99 的整数,请检查"); return; } const note = document.getElementById("f_note").value.trim(); const deceased = document.getElementById("f_deceased").checked || !!death; const rel = document.getElementById("f_rel").value; if (birth && !isValidDate(birth)) { toast("出生日期不存在或超出范围(年份 1800–2100),请检查"); return; } if (death && !isValidDate(death)) { toast("去世日期不存在或超出范围(年份 1800–2100),请检查"); return; } if (birth && death && Date.parse(death) { restoreMembers(snapshot); toast(err); }; if (editingId) { const m = byId(editingId); if (!m) { toast("成员不存在,请刷新后重试"); return; } // 先处理「解除关系」:清空关系后再改性别/其他字段,避免解除与修改性别互相冲突 if (rel === "detach") { unlinkRelations(m); } if (m.gender !== gender) { // 已录入成员若仍存在任何关系(配偶 / 父母 / 子女),须先「解除关系」才能修改性别 const hasRel = (m.spouses && m.spouses.length) || childrenOf(m.id).length || m.father || m.mother; if (hasRel) { fail("该成员存在配偶 / 父母 / 子女关系,请先「解除关系」后再修改性别"); return; } m.gender = gender; } m.name = name; m.birth = birth; m.death = death; m.rank = rank; m.note = note; m.deceased = deceased; if (rel !== "none" && rel !== "detach") { const err = applyRelation(m); if (err) { fail(err); return; } } const dateErr = validateChronology(m); if (dateErr) { fail(dateErr); return; } editingId = null; document.getElementById("f_submit").textContent = "保存成员"; } else { const m = { id: uid(), name, gender, birth, death, rank, note, deceased, father: null, mother: null, spouses: [] }; const err = applyRelation(m); if (err) { fail(err); return; } const dateErr = validateChronology(m); if (dateErr) { fail(dateErr); return; } members.push(m); } commitMembers(); clearEntryForm(); renderAll(); toast("已保存:" + name); } function editMember(id) { const m = byId(id); if (!m) return; editingId = id; document.getElementById("f_name").value = m.name; document.querySelector(`input[name=f_gender][value=${m.gender}]`).checked = true; document.getElementById("f_birth").value = m.birth || ""; document.getElementById("f_death").value = m.death || ""; document.getElementById("f_deceased").checked = !!m.deceased; document.getElementById("f_rank").value = m.rank !== "" && m.rank != null ? m.rank : ""; document.getElementById("f_note").value = m.note || ""; let rel = "none"; if (m.father || m.mother) rel = "child"; else if ((m.spouses || []).length) rel = "spouse"; else if (childrenOf(m.id).length) rel = "parent"; document.getElementById("f_rel").value = rel; refreshRelBox(); if (rel === "child") { if (m.father) document.getElementById("f_fa").value = m.father; if (m.mother) document.getElementById("f_mo").value = m.mother; } if (rel === "spouse") { document.getElementById("f_target").value = m.spouses[0]; } if (rel === "parent") { const ch = childrenOf(m.id); if (ch.length) document.getElementById("f_target").value = ch[0].id; } document.getElementById("f_submit").textContent = "更新成员"; document.getElementById("entryPanel").style.display = "block"; document.getElementById("btnToggleEntry").classList.add("active"); window.scrollTo({ top: 0, behavior: "smooth" }); } function delMember(id) { if (!confirm("确定删除该成员?相关亲缘关系会一并清理。")) return; members = members.filter(m = m.id !== id); members.forEach(m = { if (m.father === id) m.father = null; if (m.mother === id) m.mother = null; if (m.spouses) m.spouses = m.spouses.filter(s = s !== id); }); if (focusId === id) focusId = null; commitMembers(); renderAll(); renderTreeSelect(); toast("已删除"); } function clearEntryForm() { document.getElementById("f_name").value = ""; document.querySelector('input[name=f_gender][value=M]').checked = true; document.getElementById("f_birth").value = ""; document.getElementById("f_death").value = ""; document.getElementById("f_deceased").checked = false; document.getElementById("f_rank").value = ""; document.getElementById("f_note").value = ""; document.getElementById("f_rel").value = "none"; refreshRelBox(); } /* ---------- view switching ---------- */ function setMode(m) { mode = m; if (m === "global" || m === "vertical") lastMainMode = m; if (m === "family") famDir = (lastMainMode === "vertical") ? "v" : "h"; // 进入分级时默认跟随主视图方向 document.getElementById("btnGlobal").classList.toggle("active", m === "global"); document.getElementById("btnVertical").classList.toggle("active", m === "vertical"); document.getElementById("btnFamily").classList.toggle("active", m === "family"); const showGlobal = m === "global" || m === "vertical"; document.getElementById("globalHead").style.display = showGlobal ? "flex" : "none"; document.getElementById("globalWrap").style.display = showGlobal ? "block" : "none"; document.getElementById("familyHead").style.display = m === "family" ? "flex" : "none"; document.getElementById("familyWrap").style.display = m === "family" ? "block" : "none"; renderAll(); } function renderFocusSelect() { const sel = document.getElementById("focusSelect"); const cur = focusId; sel.innerHTML = memberOptions(""); if (cur && byId(cur)) sel.value = cur; sel.onchange = () = { focusId = sel.value; renderFamily(); }; } /* ---------- 多亲缘操作 ---------- */ function newTree() { const name = prompt("请输入亲缘名称:", "我的亲缘"); if (name === null) return; const surname = prompt("请输入本族姓氏(可留空,用于标记异姓成员为虚线):", ""); const t = { id: uid(), name: (name.trim() || "我的亲缘"), surname: (surname || "").trim(), location: "", theme: "xuan", font: "kai", lineWidth: 3, dashForeign: true, members: [] }; trees.push(t); currentTreeId = t.id; members = []; dragOffsets = {}; focusId = null; commitMembers(); applyTreeViewState(); renderAll(); renderTreeSelect(); toast("已新建亲缘:" + t.name); } function switchTree(id) { currentTreeId = id; const t = currentTree(); members = t ? t.members : []; dragOffsets = {}; focusId = null; saveTrees(); applyTreeViewState(); renderAll(); renderTreeSelect(); } function saveTree() { const t = currentTree(); if (!t) return; commitMembers(); toast("已保存亲缘:" + (t.name || "未命名")); } function delTree() { const t = currentTree(); if (!t) return; if (!confirm("确定删除亲缘「" + (t.name || "未命名") + "」?此操作不可恢复。")) return; trees = trees.filter(x = x.id !== t.id); if (!trees.length) trees.push({ id: uid(), name: "我的亲缘", surname: "", location: "", theme: "xuan", font: "kai", lineWidth: 3, dashForeign: true, members: [] }); currentTreeId = trees[0].id; members = currentTree().members; dragOffsets = {}; focusId = null; saveTrees(); applyTreeViewState(); renderAll(); renderTreeSelect(); toast("已删除"); } /* ---------- import / export / sample / clear ---------- */ function exportJSON() { const t = currentTree(); if (!t || !t.members.length) { toast("暂无数据"); return; } const data = { name: t.name, surname: t.surname, location: t.location, theme: t.theme, font: t.font, lineWidth: t.lineWidth, dashForeign: t.dashForeign !== false, members: t.members }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "亲缘_" + (t.name || "tree") + "_" + new Date().toISOString().slice(0, 10) + ".json"; a.click(); URL.revokeObjectURL(a.href); } function importJSON(file) { const r = new FileReader(); r.onload = () = { try { const data = JSON.parse(r.result); let tree; if (Array.isArray(data)) tree = { id: uid(), name: "导入的亲缘", surname: "", location: "", theme: "xuan", font: "kai", lineWidth: 3, dashForeign: true, members: data }; else if (data && Array.isArray(data.members)) tree = { id: uid(), name: data.name || "导入的亲缘", surname: data.surname || "", location: data.location || "", theme: data.theme || "xuan", font: data.font || "kai", lineWidth: (data.lineWidth || 3), dashForeign: data.dashForeign !== false, members: data.members }; else throw 0; trees.push(tree); currentTreeId = tree.id; members = tree.members; dragOffsets = {}; focusId = tree.members[0] ? tree.members[0].id : null; saveTrees(); applyTreeViewState(); renderAll(); renderTreeSelect(); toast("导入成功"); } catch (e) { toast("文件格式错误"); } }; r.readAsText(file); } function loadSample() { if (members.length && !confirm("载入示例会覆盖当前亲缘的成员数据,继续?")) return; members = [ { id: "g1", name: "王守义", gender: "M", birth: "1930-03-12", rank: 1, note: "家族第一代", father: null, mother: null, spouses: ["g2"] }, { id: "g2", name: "李秀兰", gender: "F", birth: "1933-07-08", rank: 1, note: "守义之妻", father: null, mother: null, spouses: ["g1"] }, { id: "g3", name: "王建国", gender: "M", birth: "1955-01-20", rank: 1, note: "长子", father: "g1", mother: "g2", spouses: ["g4"] }, { id: "g4", name: "张桂芳", gender: "F", birth: "1958-11-02", rank: 1, note: "建国之妻", father: null, mother: null, spouses: ["g3"] }, { id: "g5", name: "王建业", gender: "M", birth: "1958-09-15", rank: 2, note: "次子", father: "g1", mother: "g2", spouses: ["g6"] }, { id: "g6", name: "陈美玲", gender: "F", birth: "1961-04-19", rank: 1, note: "建业之妻", father: null, mother: null, spouses: ["g5"] }, { id: "g7", name: "王婷", gender: "F", birth: "1982-05-30", rank: 1, note: "建国长女", father: "g3", mother: "g4", spouses: [] }, { id: "g8", name: "王浩", gender: "M", birth: "1985-12-25", rank: 2, note: "建国之子", father: "g3", mother: "g4", spouses: ["g9"] }, { id: "g9", name: "赵丽", gender: "F", birth: "1987-08-14", rank: 1, note: "浩之妻", father: null, mother: null, spouses: ["g8"] }, { id: "g10", name: "王小雨", gender: "F", birth: "2012-06-01", rank: 1, note: "浩之女", father: "g8", mother: "g9", spouses: [] }, { id: "g11", name: "王磊", gender: "M", birth: "1990-02-09", rank: 1, note: "建业之子", father: "g5", mother: "g6", spouses: [] } ]; const t = currentTree(); if (t) { t.surname = "王"; t.theme = "xuan"; t.font = "kai"; } focusId = "g3"; commitMembers(); applyTreeViewState(); renderAll(); renderTreeSelect(); toast("已载入示例亲缘"); } function clearAll() { if (!members.length) return; if (!confirm("确定清空当前亲缘的所有成员?此操作不可恢复。")) return; members = []; focusId = null; commitMembers(); renderAll(); renderTreeSelect(); toast("已清空"); } /* ---------- toast ---------- */ let toastTimer = null; function toast(msg) { const t = document.getElementById("toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() = t.classList.remove("show"), 1800); } /* ---------- export png / csv ---------- */ function currentViewSvg() { if (mode === "global" || mode === "vertical") return document.getElementById("globalSvg"); const v = document.getElementById("familyView"); return v ? v.querySelector("svg") : null; } function resolveVar(name) { const rootStyle = getComputedStyle(document.documentElement); return (rootStyle.getPropertyValue(name) || "").trim(); } /* SVG 序列化前把 var(--xxx) 替换为实际色值,否则独立导出时 CSS 变量无效 */ function inlineCssVars(svg) { const clone = svg.cloneNode(true); const cache = {}; clone.querySelectorAll("*").forEach(n = { ["stroke", "fill"].forEach(attr = { const val = n.getAttribute(attr); if (val && val.indexOf("var(") = 0) { const m = val.match(/var\((--[\w-]+)\)/g); let out = val; (m || []).forEach(v = { const name = v.slice(4, -1); if (!cache[name]) cache[name] = resolveVar(name); out = out.split(v).join(cache[name] || ""); }); n.setAttribute(attr, out); } }); }); return clone; } /* ---------- 顶部亲缘全称标题 + 古风装饰边框(预览与导出共用) ---------- */ const HEADER_H = 116; function buildTitleDecor(w) { const g = document.createElementNS(SVGNS, "g"); const lines = treeTitleLines(); const hasLoc = lines.length 1; const main = hasLoc ? lines[1] : lines[0]; const loc = hasLoc ? lines[0] : ""; // 标题字号与字距按画框宽度收敛,较长亲缘名也不会越出边框。 const mainLen = Math.max(Array.from(main || "").length, 1); const mainSize = Math.max(20, Math.min(42, (w - 100) / mainLen)); const mainSpacing = Math.max(1, Math.min(14, (w - 100 - mainSize * mainLen) / Math.max(1, mainLen - 1))); const lMain = document.createElementNS(SVGNS, "text"); lMain.setAttribute("x", w / 2); lMain.setAttribute("y", 48); lMain.setAttribute("text-anchor", "middle"); lMain.setAttribute("font-size", mainSize.toFixed(1)); lMain.setAttribute("letter-spacing", mainSpacing.toFixed(1)); const mainNatural = mainSize * mainLen + mainSpacing * Math.max(0, mainLen - 1); if (mainNatural w - 100) { lMain.setAttribute("textLength", w - 100); lMain.setAttribute("lengthAdjust", "spacingAndGlyphs"); } lMain.setAttribute("fill", "var(--red)"); lMain.setAttribute("font-family", currentFont); lMain.textContent = main; g.appendChild(lMain); // 地址(在亲缘名下一行) if (loc) { const lLoc = document.createElementNS(SVGNS, "text"); lLoc.setAttribute("x", w / 2); lLoc.setAttribute("y", 74); lLoc.setAttribute("text-anchor", "middle"); lLoc.setAttribute("font-size", 15); lLoc.setAttribute("fill", "var(--ink-soft)"); lLoc.setAttribute("font-family", currentFont); lLoc.textContent = fitText(loc, w - 80, 15); g.appendChild(lLoc); } // 副标题(与 banner 一致) const lSub = document.createElementNS(SVGNS, "text"); lSub.setAttribute("x", w / 2); lSub.setAttribute("y", loc ? 96 : 90); lSub.setAttribute("text-anchor", "middle"); lSub.setAttribute("font-size", 14); lSub.setAttribute("letter-spacing", 4); lSub.setAttribute("fill", "var(--ink-soft)"); lSub.setAttribute("font-family", currentFont); lSub.textContent = "承先启后 · 维系亲缘"; g.appendChild(lSub); // 金色分隔线 + 中心菱钻(与 banner 一致) const dy = loc ? 110 : 104; g.appendChild(el("line", { x1: w / 2 - 70, y1: dy, x2: w / 2 + 70, y2: dy, stroke: "var(--gold)", "stroke-width": 1 })); g.appendChild(el("rect", { x: w / 2 - 9, y: dy - 8, width: 18, height: 16, fill: "var(--bg)" })); const dmd = document.createElementNS(SVGNS, "text"); dmd.setAttribute("x", w / 2); dmd.setAttribute("y", dy + 4); dmd.setAttribute("text-anchor", "middle"); dmd.setAttribute("font-size", 12); dmd.setAttribute("fill", "var(--gold)"); dmd.setAttribute("font-family", currentFont); dmd.textContent = "❖"; g.appendChild(dmd); // 小朱印与祥云纹,作为标题区的古风识别元素。 const sealX = w - 52, sealY = 25; g.appendChild(el("rect", { x: sealX, y: sealY, width: 27, height: 27, rx: 3, fill: "var(--red)", opacity: .92 })); g.appendChild(el("rect", { x: sealX + 3, y: sealY + 3, width: 21, height: 21, rx: 2, fill: "none", stroke: "var(--paper)", "stroke-width": 1 })); const sealText = el("text", { x: sealX + 13.5, y: sealY + 19, "text-anchor": "middle", "font-size": 13, fill: "var(--paper)", "font-family": currentFont }); sealText.textContent = "谱"; g.appendChild(sealText); const cloud = `M 28 82 C 36 72, 48 73, 52 82 C 58 72, 72 75, 73 84 L 112 84 M ${w - 28} 82 C ${w - 36} 72, ${w - 48} 73, ${w - 52} 82 C ${w - 58} 72, ${w - 72} 75, ${w - 73} 84 L ${w - 112} 84`; g.appendChild(el("path", { d: cloud, fill: "none", stroke: "var(--gold)", "stroke-width": 1.2, opacity: .55 })); return g; } function buildThemeBackdrop(w, h) { const g = el("g", { "pointer-events": "none", opacity: .20 }); const theme = (currentTree() && currentTree().theme) || "xuan"; if (theme === "zhu") { g.appendChild(el("path", { d: `M 18 ${h - 22} C 48 ${h - 44}, 68 ${h - 74}, 112 ${h - 91} M 65 ${h - 63} Q 47 ${h - 90} 29 ${h - 99}`, fill: "none", stroke: "var(--ink-soft)", "stroke-width": 3 })); [[29, h - 100], [54, h - 79], [68, h - 64], [96, h - 84]].forEach(([cx, cy]) = { for (let i = 0; i { g.appendChild(el("line", { x1: x, y1: h - 18, x2: x + 8, y2: h - 126 + i * 14, stroke: "var(--line)", "stroke-width": 4 - i })); for (let y = h - 48 - i * 6; y h - 112 + i * 12; y -= 30) { g.appendChild(el("path", { d: `M ${x + 3} ${y} q 17 -12 25 -3 q -15 12 -25 3 M ${x + 4} ${y - 7} q -15 -11 -23 -1 q 14 10 23 1`, fill: "var(--line)" })); } }); } else if (theme === "ye") { g.appendChild(el("circle", { cx: 45, cy: 48, r: 22, fill: "var(--gold)", opacity: .72 })); [[82, 32], [98, 60], [w - 42, 45]].forEach(([cx, cy], i) = g.appendChild(el("circle", { cx, cy, r: i === 1 ? 1.5 : 2.2, fill: "var(--gold)" }))); } else if (theme === "yu") { g.appendChild(el("circle", { cx: w - 48, cy: h - 48, r: 27, fill: "none", stroke: "var(--line)", "stroke-width": 10 })); g.appendChild(el("circle", { cx: w - 48, cy: h - 48, r: 8, fill: "var(--paper)" })); g.appendChild(el("path", { d: `M 24 67 q 18 -17 35 0 q 17 -14 34 1 h 38`, fill: "none", stroke: "var(--gold)", "stroke-width": 2.5 })); } else if (theme === "qiu") { [[38, 76, -18], [76, 50, 16], [w - 45, h - 54, -10]].forEach(([x, y, r]) = { g.appendChild(el("path", { d: `M ${x} ${y} l -5 -8 l 7 2 l 3 -8 l 3 8 l 8 -3 l -6 9 l 5 4 l -8 1 l -2 8 l -3 -7 l -8 2 z`, fill: "var(--red)", transform: `rotate(${r} ${x} ${y})` })); }); } else if (theme === "lian") { const cx = w - 63, y = h - 25; g.appendChild(el("path", { d: `M ${cx} ${y} C ${cx - 22} ${y - 10},${cx - 27} ${y - 32},${cx - 5} ${y - 20} C ${cx - 2} ${y - 45},${cx + 2} ${y - 45},${cx + 5} ${y - 20} C ${cx + 27} ${y - 32},${cx + 22} ${y - 10},${cx} ${y} Z`, fill: "var(--red)" })); g.appendChild(el("ellipse", { cx: cx - 34, cy: y + 2, rx: 25, ry: 7, fill: "var(--line)" })); } else { g.appendChild(el("path", { d: `M 17 ${h - 20} Q 43 ${h - 70} 70 ${h - 20} Q 97 ${h - 88} 126 ${h - 20}`, fill: "var(--line)" })); g.appendChild(el("path", { d: `M 20 ${h - 16} Q 38 ${h - 23} 56 ${h - 16} T 92 ${h - 16}`, fill: "none", stroke: "var(--gold)", "stroke-width": 2 })); } return g; } function buildAncientDecor(w, h) { const g = el("g", { "pointer-events": "none" }); const y = h - 17; // 仅保留底部两角的短水纹和标题两侧如意纹。 g.appendChild(el("path", { d: `M 24 ${y} q 18 -7 36 0 t 36 0 M ${w - 96} ${y} q 18 -7 36 0 t 36 0`, fill: "none", stroke: "var(--gold)", "stroke-width": 1, opacity: .30 })); g.appendChild(el("path", { d: `M 18 126 q 13 -13 26 0 q -13 13 -26 0 M ${w - 18} 126 q -13 -13 -26 0 q 13 13 26 0`, fill: "none", stroke: "var(--red)", "stroke-width": 1.1, opacity: .34 })); return g; } function buildFrame(w, h) { const g = el("g", {}); g.appendChild(el("rect", { x: 5, y: 5, width: w - 10, height: h - 10, rx: 12, fill: "none", stroke: "var(--red)", "stroke-width": 2 })); g.appendChild(el("rect", { x: 11, y: 11, width: w - 22, height: h - 22, rx: 8, fill: "none", stroke: "var(--gold)", "stroke-width": 1 })); [[14, 14, 1, 1], [w - 14, 14, -1, 1], [14, h - 14, 1, -1], [w - 14, h - 14, -1, -1]].forEach(([cx, cy, sx, sy]) = { g.appendChild(el("path", { d: `M ${cx} ${cy + 12 * sy} L ${cx} ${cy} L ${cx + 12 * sx} ${cy}`, fill: "none", stroke: "var(--red)", "stroke-width": 2 })); g.appendChild(el("circle", { cx: cx, cy: cy, r: 2, fill: "var(--gold)" })); }); return g; } function addTitleDecor(svg, W, H, eff) { const totalH = H + HEADER_H; const wrap = el("g", { transform: `translate(0,${HEADER_H})` }); while (svg.firstChild) wrap.appendChild(svg.firstChild); svg.appendChild(el("rect", { x: 0, y: 0, width: W, height: totalH, fill: "var(--paper)" })); svg.appendChild(buildThemeBackdrop(W, totalH)); svg.appendChild(wrap); svg.appendChild(buildTitleDecor(W)); svg.appendChild(buildAncientDecor(W, totalH)); svg.appendChild(buildFrame(W, totalH)); svg.setAttribute("viewBox", `0 0 ${W} ${totalH}`); svg.setAttribute("width", W * eff); svg.setAttribute("height", totalH * eff); } function exportPNG() { const t = currentTree(); if (!t || !t.members.length) { toast("暂无数据"); return; } const svg = currentViewSvg(); if (!svg) { toast("暂无预览图"); return; } // 标题与古风装饰已烘焙进实时预览,此处直接序列化导出 const clone = inlineCssVars(svg); const vb = (clone.getAttribute("viewBox") || "0 0 100 100").split(" ").map(Number); const w = vb[2], h = vb[3]; clone.setAttribute("width", w); clone.setAttribute("height", h); const xml = new XMLSerializer().serializeToString(clone); const blob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(blob); const img = new Image(); img.onload = () = { const canvas = document.createElement("canvas"); canvas.width = w * 2; canvas.height = h * 2; const ctx = canvas.getContext("2d"); ctx.fillStyle = resolveVar("--bg") || "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); const a = document.createElement("a"); a.download = "亲缘_" + (t.name || "tree") + "_" + new Date().toISOString().slice(0, 10) + ".png"; a.href = canvas.toDataURL("image/png"); a.click(); URL.revokeObjectURL(url); toast("图片已导出"); }; img.onerror = () = { URL.revokeObjectURL(url); toast("图片导出失败"); }; img.src = url; } function exportCSV() { const t = currentTree(); if (!t || !t.members.length) { toast("暂无数据"); return; } const head = ["姓名", "性别", "生辰", "卒日", "排行", "备注", "父亲", "母亲", "配偶", "子女"]; const rows = members.map(m = { const fa = m.father ? byId(m.father) : null, mo = m.mother ? byId(m.mother) : null; const sp = (m.spouses || []).map(byId).filter(Boolean).map(s = s.name).join("、"); const ch = childrenOf(m.id).map(c = c.name).join("、"); return [m.name, m.gender === "F" ? "女" : "男", m.birth || "", m.death || "", (m.rank !== "" && m.rank != null) ? m.rank : "", m.note || "", fa ? fa.name : "", mo ? mo.name : "", sp, ch]; }); const csv = [head, ...rows].map(r = r.map(v = { const s = String(v == null ? "" : v); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }).join(",")).join("\r\n"); const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "亲缘_" + (t.name || "tree") + "_" + new Date().toISOString().slice(0, 10) + ".csv"; a.click(); URL.revokeObjectURL(a.href); toast("表格已导出"); } /* ---------- 全屏展示:支持滚轮缩放、拖动平移、缩放按钮、滚动条 ---------- */ let fsScale = 1, fsBase = null, fsPan = null; function fsApply() { const cv = document.querySelector("#fsContent svg"); if (!cv || !fsBase) return; cv.setAttribute("width", (fsBase.w * fsScale).toFixed(1)); cv.setAttribute("height", (fsBase.h * fsScale).toFixed(1)); } function fsZoom(factor) { if (!fsBase) return; fsScale = Math.max(0.1, Math.min(6, fsScale * factor)); fsApply(); } function fsFit() { if (!fsBase) return; const availW = window.innerWidth - 48, availH = window.innerHeight - 110; fsScale = Math.max(0.1, Math.min(availW / fsBase.w, availH / fsBase.h, 1)); fsApply(); } function openFullScreen() { let svg = null; if (mode === "family") { const v = document.getElementById("familyView"); svg = v ? v.querySelector("svg") : null; } else svg = document.getElementById("globalSvg"); if (!svg) { toast("暂无预览图"); return; } const wrap = document.getElementById("fsWrap"); wrap.innerHTML = ""; const clone = svg.cloneNode(true); const vb = (svg.getAttribute("viewBox") || "0 0 800 600").split(" ").map(Number); fsBase = { w: (vb[2] || 800), h: (vb[3] || 600) }; fsScale = 1; fsFit(); clone.setAttribute("width", (fsBase.w * fsScale).toFixed(1)); clone.setAttribute("height", (fsBase.h * fsScale).toFixed(1)); clone.style.cursor = "grab"; const holder = document.createElement("div"); holder.id = "fsContent"; holder.appendChild(clone); wrap.appendChild(holder); document.getElementById("fsTitle").textContent = mode === "family" ? "分级家庭亲缘" : mode === "vertical" ? "竖排亲缘" : "全局亲缘"; document.getElementById("fsMask").classList.add("show"); } function closeFullScreen() { document.getElementById("fsMask").classList.remove("show"); document.getElementById("fsWrap").innerHTML = ""; fsBase = null; fsScale = 1; } function onFsWheel(e) { if (!document.getElementById("fsMask").classList.contains("show") || !fsBase) return; e.preventDefault(); fsZoom(e.deltaY 0 ? 0.9 : 1.1); } function onFsPointerDown(e) { if (!document.getElementById("fsMask").classList.contains("show")) return; if (e.button !== 0) return; const wrap = document.getElementById("fsWrap"); fsPan = { sx: e.clientX, sy: e.clientY, sl: wrap.scrollLeft, st: wrap.scrollTop }; const cv = document.querySelector("#fsContent svg"); if (cv) cv.style.cursor = "grabbing"; window.addEventListener("pointermove", onFsMove); window.addEventListener("pointerup", onFsUp); } function onFsMove(e) { if (!fsPan) return; const wrap = document.getElementById("fsWrap"); wrap.scrollLeft = fsPan.sl - (e.clientX - fsPan.sx); wrap.scrollTop = fsPan.st - (e.clientY - fsPan.sy); } function onFsUp() { fsPan = null; const cv = document.querySelector("#fsContent svg"); if (cv) cv.style.cursor = "grab"; window.removeEventListener("pointermove", onFsMove); window.removeEventListener("pointerup", onFsUp); } /* ---------- render all ---------- */ function renderAll() { renderMemberList(); renderFocusSelect(); if (mode === "global") renderGlobal(); else if (mode === "vertical") renderVertical(); else renderFamily(); } /* ---------- wire events ---------- */ document.getElementById("btnToggleEntry").addEventListener("click", () = { const p = document.getElementById("entryPanel"); const show = p.style.display === "none"; p.style.display = show ? "block" : "none"; document.getElementById("btnToggleEntry").classList.toggle("active", show); // 展开时显示分割线(各尺寸窗口均可用,含竖排窄窗口),折叠时隐藏 const sp = document.getElementById("splitter"); if (sp) sp.style.display = show ? "block" : "none"; if (show) clearEntryForm(); }); document.getElementById("btnGlobal").addEventListener("click", () = setMode("global")); document.getElementById("btnVertical").addEventListener("click", () = setMode("vertical")); document.getElementById("btnFamily").addEventListener("click", () = setMode("family")); document.getElementById("btnExport").addEventListener("click", exportJSON); document.getElementById("btnImport").addEventListener("click", () = document.getElementById("fileInput").click()); document.getElementById("fileInput").addEventListener("change", e = { if (e.target.files[0]) importJSON(e.target.files[0]); e.target.value = ""; }); document.getElementById("btnSample").addEventListener("click", loadSample); document.getElementById("btnClear").addEventListener("click", clearAll); document.getElementById("f_rel").addEventListener("change", refreshRelBox); // 性别切换时若正在选配偶,刷新下拉(配偶只能选异性) document.querySelectorAll('input[name=f_gender]').forEach(r = r.addEventListener("change", () = { if (document.getElementById("f_rel").value === "spouse") refreshRelBox(); })); document.getElementById("f_submit").addEventListener("click", submitMember); document.getElementById("zoomIn").addEventListener("click", () = { autoFit = false; zoom = Math.min(3, zoom * 1.15); renderAll(); }); document.getElementById("zoomOut").addEventListener("click", () = { autoFit = false; zoom = Math.max(0.3, zoom / 1.15); renderAll(); }); document.getElementById("fitGlobal").addEventListener("click", () = { autoFit = true; renderAll(); }); document.getElementById("fitFamily").addEventListener("click", () = { autoFit = true; renderAll(); }); document.getElementById("refreshView").addEventListener("click", () = { renderAll(); toast("预览已刷新"); }); document.getElementById("refreshView2").addEventListener("click", () = { renderAll(); toast("预览已刷新"); }); document.getElementById("resetLayout").addEventListener("click", () = { dragOffsets = {}; saveOffsets(); renderAll(); toast("布局已重置"); }); document.getElementById("famHorizontal").addEventListener("click", () = { famDir = "h"; renderFamily(); }); document.getElementById("famVertical").addEventListener("click", () = { famDir = "v"; renderFamily(); }); window.addEventListener("resize", () = { if (autoFit) renderAll(); }); document.getElementById("navParent").addEventListener("click", () = { const C = byId(focusId); if (!C) return; const t = C.father || C.mother; if (t) { focusId = t; renderFamily(); toast("已切换到上一代"); } else toast("无父母记录"); }); document.getElementById("navChild").addEventListener("click", () = { const ch = childrenOf(focusId); if (ch.length) { focusId = ch[0].id; renderFamily(); toast("已切换到子女"); } else toast("无子女记录"); }); document.getElementById("navSpouse").addEventListener("click", () = { const C = byId(focusId); if (C && (C.spouses || []).length) { focusId = C.spouses[0]; renderFamily(); toast("已切换到配偶"); } else toast("无配偶记录"); }); document.getElementById("mask").addEventListener("click", e = { if (e.target.id === "mask") closeModal(); }); /* 亲缘管理 / 主题 / 字体 */ document.getElementById("treeSelect").addEventListener("change", e = switchTree(e.target.value)); document.getElementById("btnNewTree").addEventListener("click", newTree); document.getElementById("btnSaveTree").addEventListener("click", saveTree); document.getElementById("btnDelTree").addEventListener("click", delTree); document.getElementById("surnameInput").addEventListener("input", e = { const t = currentTree(); if (t) { t.surname = e.target.value.trim(); saveTrees(); renderAll(); updateBannerTitle(); } }); document.getElementById("locationInput").addEventListener("input", e = { const t = currentTree(); if (t) { t.location = e.target.value.trim(); saveTrees(); updateBannerTitle(); } }); document.getElementById("themeSelect").addEventListener("change", e = { const t = currentTree(); if (t) { t.theme = e.target.value; applyTheme(t.theme); saveTrees(); renderAll(); } }); document.getElementById("fontSelect").addEventListener("change", e = { const t = currentTree(); if (t) { t.font = e.target.value; applyFont(t.font); saveTrees(); renderAll(); } }); document.getElementById("lineWidthSelect").addEventListener("change", e = { const t = currentTree(); if (t) { t.lineWidth = parseFloat(e.target.value) || 3; saveTrees(); renderAll(); } }); document.getElementById("dashForeign").addEventListener("change", e = { const t = currentTree(); if (t) { t.dashForeign = e.target.checked; saveTrees(); renderAll(); } }); /* 成员框展示字段:勾选即时生效并保存 */ document.querySelectorAll(".fld").forEach(cb = cb.addEventListener("change", () = { const t = currentTree(); if (!t) return; t.fields = t.fields || { gender: true, rank: true, birth: true, death: true, deceasedBg: false, note: false }; t.fields[cb.value] = cb.checked; saveTrees(); renderAll(); })); document.getElementById("fontSizeInput").addEventListener("change", e = { textSize = clampText(e.target.value); saveTextSize(); applyTextSize(); }); document.getElementById("fontSizeMinus").addEventListener("click", () = { textSize = clampText(textSize - 2); saveTextSize(); applyTextSize(); }); document.getElementById("fontSizePlus").addEventListener("click", () = { textSize = clampText(textSize + 2); saveTextSize(); applyTextSize(); }); document.getElementById("btnExportPng").addEventListener("click", exportPNG); document.getElementById("btnExportCsv").addEventListener("click", exportCSV); document.getElementById("btnFullScreen").addEventListener("click", openFullScreen); document.getElementById("fsClose").addEventListener("click", closeFullScreen); document.getElementById("fsZoomIn").addEventListener("click", () = fsZoom(1.15)); document.getElementById("fsZoomOut").addEventListener("click", () = fsZoom(1 / 1.15)); document.getElementById("fsFit").addEventListener("click", fsFit); document.getElementById("fsWrap").addEventListener("wheel", onFsWheel, { passive: false }); document.getElementById("fsWrap").addEventListener("pointerdown", onFsPointerDown); document.addEventListener("keydown", e = { if (e.key === "Escape") closeFullScreen(); }); textSize = loadTextSize(); renderTreeSelect(); applyTreeViewState(); refreshRelBox(); bindPan(document.getElementById("globalWrap")); bindPan(document.getElementById("familyWrap")); bindSplitter(); applySplit(); document.getElementById("splitter").style.display = "none"; // 录入栏默认折叠 applyTextSize(); renderAll();