| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022 |
- {% extends "layout.html" %}
- {% block title %}世代分层家谱树 - 家谱管理系统{% endblock %}
- {% block extra_css %}
- <style>
- /* ── 容器 ── */
- #tree-gen-container {
- width: 100%;
- height: calc(100vh - 160px);
- min-height: 500px;
- background: #fafbfd;
- border: 1px solid #e9ecef;
- border-radius: 8px;
- position: relative;
- overflow: hidden;
- margin-top: 6px;
- box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
- }
- /* ── 左侧世代标签栏 ── */
- #gen-sidebar {
- position: absolute;
- left: 0; top: 0;
- width: 148px;
- height: 100%;
- z-index: 12;
- background: #f1f5f9;
- border-right: 1.5px solid #cbd5e1;
- overflow: hidden;
- /* pointer-events 由子元素各自控制 */
- }
- #gen-sidebar-title {
- position: absolute;
- top: 0; left: 0; right: 0;
- height: 38px;
- background: #e2e8f0;
- border-bottom: 1px solid #cbd5e1;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- font-weight: 600;
- color: #475569;
- font-family: 'Microsoft YaHei', sans-serif;
- z-index: 1;
- pointer-events: none;
- }
- /* 世代标签行(不可交互,仅展示) */
- .gen-label-row {
- position: absolute;
- left: 0; right: 0;
- text-align: center;
- transform: translateY(-50%);
- padding: 4px 6px;
- line-height: 1.5;
- pointer-events: none;
- user-select: none;
- }
- .gen-label-num {
- display: block;
- font-size: 13px;
- font-weight: bold;
- color: #334155;
- font-family: 'Microsoft YaHei', sans-serif;
- letter-spacing: 0.5px;
- }
- .gen-label-lineage {
- display: block;
- font-size: 10px;
- color: #94a3b8;
- font-family: 'Microsoft YaHei', sans-serif;
- margin-top: 1px;
- }
- /* ── 侧边栏折叠手柄 ── */
- .gen-collapse-handle {
- position: absolute;
- left: 0; right: 0;
- height: 20px;
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: auto;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.2s;
- z-index: 3;
- }
- #gen-sidebar:hover .gen-collapse-handle { opacity: 1; }
- .gen-collapse-handle:hover { opacity: 1 !important; }
- .gen-collapse-handle .h-line {
- position: absolute;
- left: 8px; right: 8px;
- height: 1.5px;
- background: #fbbf24;
- border-radius: 1px;
- }
- .gen-collapse-handle .h-btn {
- position: absolute;
- right: 6px;
- width: 16px; height: 16px;
- background: #fbbf24;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 9px;
- color: white;
- font-weight: 900;
- line-height: 1;
- box-shadow: 0 1px 3px rgba(0,0,0,0.2);
- }
- .gen-collapse-handle:hover .h-line { background: #f59e0b; height: 2px; }
- .gen-collapse-handle:hover .h-btn { background: #f59e0b; transform: scale(1.15); }
- /* 第一次点击后高亮 */
- .gen-collapse-handle.first-selected .h-line { background: #ef4444; height: 2px; }
- .gen-collapse-handle.first-selected .h-btn { background: #ef4444; }
- /* ── 已折叠世代行 ── */
- .gen-collapsed-row {
- position: absolute;
- left: 4px; right: 4px;
- transform: translateY(-50%);
- background: #fffbeb;
- border: 1.5px dashed #fbbf24;
- border-radius: 5px;
- padding: 4px 4px;
- text-align: center;
- pointer-events: auto;
- cursor: pointer;
- font-family: 'Microsoft YaHei', sans-serif;
- }
- .gen-collapsed-row:hover { background: #fef3c7; border-color: #f59e0b; }
- .gen-collapsed-row .cr-title {
- display: block;
- font-size: 11px;
- font-weight: bold;
- color: #92400e;
- line-height: 1.4;
- }
- .gen-collapsed-row .cr-hint {
- display: block;
- font-size: 9px;
- color: #b45309;
- margin-top: 1px;
- }
- /* ── SVG 节点 ── */
- .gen-node rect, .gen-node circle {
- stroke-width: 2px;
- filter: drop-shadow(0 2px 3px rgba(0,0,0,0.12));
- }
- .node-male rect { stroke: #3B82F6; fill: #EFF6FF; }
- .node-female circle { stroke: #EC4899; fill: #FDF2F8; }
- .node-unknown circle, .node-unknown rect { stroke: #94A3B8; fill: #F8FAFC; }
- .node-name-text {
- font-family: 'Microsoft YaHei', sans-serif;
- font-size: 12px;
- fill: #334155;
- stroke: white;
- stroke-width: 3.5px;
- paint-order: stroke;
- stroke-linejoin: round;
- pointer-events: none;
- }
- /* 高亮 */
- .node-highlight rect { stroke: #ef4444 !important; stroke-width: 3px !important; }
- .node-highlight circle { stroke: #ef4444 !important; stroke-width: 3px !important; }
- /* 入继节点:虚线边框 */
- .node-adopted-in rect { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
- .node-adopted-in circle { stroke-dasharray: 5,3; stroke: #f59e0b !important; }
- .node-adopt-label {
- font-family: 'Microsoft YaHei', sans-serif;
- font-size: 9px;
- fill: #f59e0b;
- stroke: white;
- stroke-width: 2px;
- paint-order: stroke;
- pointer-events: none;
- }
- /* ── 连线 ── */
- .link-parent { fill: none; stroke: #94a3b8; stroke-width: 1.6px; stroke-linejoin: round; }
- .link-spouse { fill: none; stroke: #f59e0b; stroke-width: 1.4px; stroke-dasharray: 5,3; }
- /* 折叠穿透虚线 */
- .link-collapse { fill: none; stroke: #f59e0b; stroke-width: 2px; stroke-dasharray: 8,4; }
- /* ── 折叠区间背景 ── */
- .collapse-stripe { fill: #fef3c7; opacity: 0.55; }
- /* ── 缩放控件 ── */
- .zoom-controls {
- position: absolute;
- top: 10px; right: 10px;
- z-index: 100;
- background: white;
- border-radius: 6px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.12);
- padding: 4px;
- }
- .zoom-btn {
- display: flex;
- width: 28px; height: 28px;
- margin: 3px 0;
- border: 1px solid #e2e8f0;
- border-radius: 4px;
- background: white; cursor: pointer;
- align-items: center; justify-content: center;
- font-size: 14px; color: #475569;
- }
- .zoom-btn:hover { background: #f1f5f9; color: #0f172a; }
- /* ── 右键菜单 ── */
- .context-menu {
- position: absolute;
- display: none;
- background: white;
- border: 1px solid #e2e8f0;
- box-shadow: 0 4px 16px rgba(0,0,0,0.15);
- z-index: 200;
- border-radius: 6px;
- padding: 4px 0;
- min-width: 130px;
- }
- .context-menu-item {
- padding: 8px 14px;
- cursor: pointer;
- font-size: 13px;
- color: #334155;
- display: flex;
- align-items: center;
- gap: 6px;
- }
- .context-menu-item:hover { background: #f1f5f9; color: #0d6efd; }
- /* ── 操作提示浮层 ── */
- #collapse-hint-float {
- position: absolute;
- bottom: 12px; left: 50%;
- transform: translateX(-50%);
- background: rgba(245,158,11,0.92);
- color: white;
- padding: 6px 14px;
- border-radius: 20px;
- font-size: 12px;
- font-family: 'Microsoft YaHei', sans-serif;
- z-index: 300;
- pointer-events: none;
- display: none;
- white-space: nowrap;
- box-shadow: 0 2px 10px rgba(0,0,0,0.2);
- }
- </style>
- {% endblock %}
- {% block content %}
- <div class="d-flex justify-content-between align-items-center mb-2">
- <h2><i class="bi bi-layout-three-columns me-2"></i>世代分层家谱树</h2>
- <div class="d-flex gap-2">
- <div class="input-group" style="width:260px">
- <input id="genSearch" type="text" class="form-control form-control-sm" placeholder="输入成员名搜索定位">
- <button class="btn btn-sm btn-primary" onclick="searchMember()"><i class="bi bi-search"></i></button>
- </div>
- <a href="{{ url_for('tree') }}" class="btn btn-outline-secondary btn-sm">
- <i class="bi bi-diagram-3 me-1"></i>标准树状图
- </a>
- <a href="{{ url_for('tree_classic') }}" class="btn btn-outline-secondary btn-sm">
- <i class="bi bi-printer me-1"></i>传统吊线图
- </a>
- </div>
- </div>
- <!-- ── 向上收紧工具栏 ── -->
- <div class="d-flex align-items-center gap-2 mb-2 p-2 rounded border bg-warning bg-opacity-10">
- <i class="bi bi-arrows-collapse text-warning"></i>
- <span class="small fw-semibold text-warning-emphasis">向上收紧:</span>
- <span class="small text-secondary">折叠第</span>
- <input id="collapseFrom" type="number" class="form-control form-control-sm text-center"
- style="width:62px" min="1" placeholder="起">
- <span class="small text-secondary">至</span>
- <input id="collapseTo" type="number" class="form-control form-control-sm text-center"
- style="width:62px" min="1" placeholder="止">
- <span class="small text-secondary">世(区间)</span>
- <button class="btn btn-sm btn-warning" onclick="applyCollapseFromInput()">收起</button>
- <button class="btn btn-sm btn-outline-secondary" onclick="clearAllCollapse()">展开全部</button>
- <span class="small text-muted ms-1">或在左侧标签栏点击两次
- <span style="display:inline-block;width:14px;height:14px;background:#fbbf24;border-radius:50%;vertical-align:middle;font-size:9px;line-height:14px;text-align:center;color:#fff;font-weight:900">─</span>
- 手柄折叠
- </span>
- </div>
- <div class="alert alert-light border small py-1 mb-2">
- <i class="bi bi-info-circle me-1"></i>
- 每行 = 同一世代。<strong>向上收紧</strong>:将中间世代压缩为虚线,保留两端节点。
- 男性方形<span style="display:inline-block;width:12px;height:12px;background:#EFF6FF;border:2px solid #3B82F6;border-radius:2px;vertical-align:middle;margin:0 3px"></span>,
- 女性圆形<span style="display:inline-block;width:12px;height:12px;background:#FDF2F8;border:2px solid #EC4899;border-radius:50%;vertical-align:middle;margin:0 3px"></span>,
- 橙色虚线=折叠穿透。右键可查看/编辑。
- </div>
- <div id="tree-gen-container">
- <!-- 左侧世代标签栏 -->
- <div id="gen-sidebar">
- <div id="gen-sidebar-title">世代</div>
- </div>
- <!-- 缩放控件 -->
- <div class="zoom-controls">
- <button class="zoom-btn" onclick="zoomIn()" title="放大"><i class="bi bi-plus"></i></button>
- <button class="zoom-btn" onclick="zoomOut()" title="缩小"><i class="bi bi-dash"></i></button>
- <button class="zoom-btn" onclick="zoomReset()" title="重置"><i class="bi bi-arrow-counterclockwise"></i></button>
- </div>
- <!-- 右键菜单 -->
- <div id="contextMenuGen" class="context-menu">
- <div class="context-menu-item" onclick="menuAction('detail')"><i class="bi bi-eye"></i>查看成员</div>
- <div class="context-menu-item" onclick="menuAction('edit')"><i class="bi bi-pencil"></i>编辑成员</div>
- <div class="context-menu-item" onclick="menuAction('add')"><i class="bi bi-plus-lg"></i>新增成员</div>
- </div>
- <!-- 操作提示浮层 -->
- <div id="collapse-hint-float"></div>
- <!-- 加载提示 -->
- <div id="gen-loading" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:#94a3b8;z-index:5">
- <div class="spinner-border spinner-border-sm mb-2" role="status"></div>
- <div style="font-size:13px">加载中...</div>
- </div>
- </div>
- {% endblock %}
- {% block extra_js %}
- <script src="{{ url_for('static', filename='js/d3.min.js') }}"></script>
- <script>
- if (typeof d3 === 'undefined') {
- var _ds = document.createElement('script');
- _ds.src = 'https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js';
- _ds.onload = startInit;
- _ds.onerror = () => {
- document.getElementById('gen-loading').innerHTML =
- '<div class="text-danger small">D3.js 未加载,请检查网络。</div>';
- };
- document.head.appendChild(_ds);
- } else {
- startInit();
- }
- // ═══════════════════════════════════════════════════════════
- // 配置常量
- // ═══════════════════════════════════════════════════════════
- const CFG = {
- SIDEBAR_W: 150, // 侧边栏宽度
- ROW_H: 160, // 正常行高
- COLLAPSED_H: 80, // 折叠区间虚拟行高
- NODE_SPC: 100, // 水平节点间距
- NODE_R: 22, // 节点半径
- TOP_PAD: 60, // 顶部留白
- LEFT_PAD: 30, // 侧边栏右侧额外留白
- MAX_NAME: 6, // 节点显示最大字符数
- };
- // ═══════════════════════════════════════════════════════════
- // 全局状态
- // ═══════════════════════════════════════════════════════════
- let _rawData = null;
- let _mMap = null;
- let _minGen = 1;
- let _maxGen = 1;
- let _lineageRef = null;
- let _zoomBhv = null;
- let _svgEl = null;
- let _mainG = null;
- let _selMid = null;
- let _collapsedRanges = []; // [{start, end}, ...] 当前所有折叠区间
- let _collapseFirst = null; // 侧边栏手柄:第一次点击的 betweenGen 值
- const _ctxMenu = document.getElementById('contextMenuGen');
- window.addEventListener('click', () => { _ctxMenu.style.display = 'none'; });
- // ═══════════════════════════════════════════════════════════
- // 中文数字工具
- // ═══════════════════════════════════════════════════════════
- const _CN_D = ['零','一','二','三','四','五','六','七','八','九'];
- const _CN_U = ['','十','百','千','万'];
- function numToCN(n) {
- if (n <= 0) return '零';
- if (n <= 10) return n === 10 ? '十' : _CN_D[n];
- if (n < 20) return '十' + (n % 10 === 0 ? '' : _CN_D[n % 10]);
- let r = '', s = String(n);
- for (let i = 0; i < s.length; i++) {
- const d = +s[i], u = s.length - 1 - i;
- if (d === 0) { if (r && !r.endsWith('零') && i < s.length-1) r += '零'; }
- else r += _CN_D[d] + _CN_U[u];
- }
- return r.replace(/零+$/, '');
- }
- function cnToNum(s) {
- if (!s) return NaN;
- const dm = {'零':0,'一':1,'二':2,'三':3,'四':4,'五':5,'六':6,'七':7,'八':8,'九':9};
- const um = {'十':10,'百':100,'千':1000,'万':10000};
- let r = 0, t = 0;
- for (const ch of s.trim()) {
- if (dm[ch] !== undefined) t = dm[ch];
- else if (um[ch] !== undefined) { if (!t && um[ch]===10) t=1; r += t*um[ch]; t=0; }
- }
- return (r+t) || NaN;
- }
- function extractGen(str) {
- if (!str) return null;
- const m = String(str).match(/\d+/);
- return m ? parseInt(m[0]) : null;
- }
- function parseLineage(str) {
- if (!str) return [];
- return str.split(';').filter(s => s.trim()).map(item => {
- item = item.trim();
- const m = item.match(/^(.+?)第(.+?)代$/);
- if (m) return { place: m[1], num: cnToNum(m[2]) };
- return { place: '', num: NaN };
- });
- }
- function lineageLbl(refLineage, refGen, targetGen) {
- if (!refLineage || !refLineage.length) return [];
- const diff = targetGen - refGen;
- return refLineage.map(lg => {
- const n = lg.num + diff;
- if (isNaN(n) || n <= 0) return null;
- return (lg.place ? lg.place + '第' : '第') + numToCN(n) + '代';
- }).filter(Boolean);
- }
- // ═══════════════════════════════════════════════════════════
- // 折叠区间工具函数
- // ═══════════════════════════════════════════════════════════
- /** 计算 gen 在当前折叠状态下的 Y 坐标(内容坐标系) */
- function computeY(gen) {
- let y = CFG.TOP_PAD;
- let g = _minGen;
- while (g < gen) {
- const cr = _collapsedRanges.find(r => r.start <= g && g <= r.end);
- if (cr) {
- y += CFG.COLLAPSED_H; // 整个折叠区间只占 COLLAPSED_H
- g = cr.end + 1; // 跳过区间
- } else {
- y += CFG.ROW_H;
- g++;
- }
- }
- return y;
- }
- /** gen 是否在某个折叠区间内 */
- function isCollapsedGen(gen) {
- return _collapsedRanges.some(r => r.start <= gen && gen <= r.end);
- }
- /** 获取包含 gen 的折叠区间(没有则返回 null) */
- function getCollapseRange(gen) {
- return _collapsedRanges.find(r => r.start <= gen && gen <= r.end) || null;
- }
- /**
- * BFS:从 entryNode 的孩子出发,穿越折叠区间 cr,
- * 找到所有 gen > cr.end 的"出口节点"
- */
- function findExitNodes(entryNode, cr) {
- const exits = [];
- const visited = new Set([entryNode.id]);
- const queue = entryNode.children.filter(c => c.gen >= cr.start && c.gen <= cr.end);
- while (queue.length) {
- const cur = queue.shift();
- if (visited.has(cur.id)) continue;
- visited.add(cur.id);
- if (cur.gen > cr.end) {
- if (!exits.find(e => e.id === cur.id)) exits.push(cur);
- } else {
- cur.children.forEach(c => { if (!visited.has(c.id)) queue.push(c); });
- }
- }
- return exits;
- }
- // ═══════════════════════════════════════════════════════════
- // 折叠操作
- // ═══════════════════════════════════════════════════════════
- function applyCollapse(start, end) {
- if (start >= end) { alert('折叠区间无效,起始世代须小于结束世代'); return; }
- // 移除重叠的旧区间
- _collapsedRanges = _collapsedRanges.filter(r => r.end < start || r.start > end);
- _collapsedRanges.push({ start, end });
- renderGenTree(_rawData);
- }
- function expandRange(start, end) {
- _collapsedRanges = _collapsedRanges.filter(r => !(r.start === start && r.end === end));
- renderGenTree(_rawData);
- }
- function applyCollapseFromInput() {
- const from = parseInt(document.getElementById('collapseFrom').value);
- const to = parseInt(document.getElementById('collapseTo').value);
- if (isNaN(from) || isNaN(to)) { alert('请输入有效的起始和结束世代数字'); return; }
- applyCollapse(Math.min(from, to), Math.max(from, to));
- }
- function clearAllCollapse() {
- _collapsedRanges = [];
- renderGenTree(_rawData);
- }
- // ── 侧边栏手柄点击逻辑 ──
- function handleCollapseClick(betweenGen, el) {
- if (_collapseFirst === null) {
- // 第一次点击
- _collapseFirst = betweenGen;
- document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
- el.classList.add('first-selected');
- _showHint('✓ 已标记折叠起点(第' + numToCN(betweenGen) + '/'+ numToCN(betweenGen+1) +'世之间),再点一个位置完成收紧');
- } else {
- // 第二次点击
- const first = _collapseFirst;
- const second = betweenGen;
- _collapseFirst = null;
- document.querySelectorAll('.gen-collapse-handle').forEach(h => h.classList.remove('first-selected'));
- _hideHint();
- const lo = Math.min(first, second) + 1;
- const hi = Math.max(first, second);
- if (lo > hi) { alert('请选择间距至少 1 代的两个位置'); return; }
- applyCollapse(lo, hi);
- }
- }
- function _showHint(msg) {
- const el = document.getElementById('collapse-hint-float');
- el.textContent = msg;
- el.style.display = 'block';
- }
- function _hideHint() {
- document.getElementById('collapse-hint-float').style.display = 'none';
- }
- // ═══════════════════════════════════════════════════════════
- // 入口
- // ═══════════════════════════════════════════════════════════
- function startInit() {
- fetch('/manager/api/tree_data')
- .then(r => r.json())
- .then(data => {
- _rawData = data;
- document.getElementById('gen-loading').style.display = 'none';
- renderGenTree(data);
- })
- .catch(() => {
- document.getElementById('gen-loading').innerHTML =
- '<div class="text-danger small">加载失败,请刷新重试。</div>';
- });
- }
- // ═══════════════════════════════════════════════════════════
- // 主渲染函数
- // ═══════════════════════════════════════════════════════════
- function renderGenTree(data) {
- const container = document.getElementById('tree-gen-container');
- d3.select('#tree-gen-container svg').remove();
- document.querySelectorAll('#gen-sidebar .gen-label-row, #gen-sidebar .gen-collapse-handle, #gen-sidebar .gen-collapsed-row').forEach(el => el.remove());
- const { members, relations } = data;
- if (!members || !members.length) {
- container.insertAdjacentHTML('beforeend',
- '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#94a3b8">暂无成员数据。</div>');
- return;
- }
- // ── 1. 构建成员 Map ──
- const mMap = {};
- members.forEach(m => {
- mMap[m.id] = {
- id: m.id, name: m.name || '', sname: m.simplified_name || '',
- sex: m.sex, family_rank: m.family_rank, nwg: m.name_word_generation,
- gen: extractGen(m.family_rank),
- children: [], spouses: [], _pCount: 0, _spouseOf: null,
- x: 0, y: 0,
- };
- });
- // ── 2. 处理关系 ──
- const pcRels = relations.filter(r => r.relation_type === 1 || r.relation_type === 2);
- const spRels = relations.filter(r => r.relation_type === 10);
- const hasFather = new Set(relations.filter(r => r.relation_type === 1).map(r => r.child_mid));
- // 找出有"入继"养父母的子女 ID 集合(这些人应显示在养父母名下,不显示在生父母名下)
- const hasAdoptiveParent = new Set(
- pcRels.filter(r => r.sub_relation_type === 3).map(r => r.child_mid)
- );
- pcRels.forEach(r => {
- if (r.relation_type === 2 && hasFather.has(r.child_mid)) return;
- // 出继子女(生父母侧,sub_relation_type=2)且已有养父母记录 → 跳过,不显示在生父母名下
- if (r.sub_relation_type === 2 && hasAdoptiveParent.has(r.child_mid)) return;
- const p = mMap[r.parent_mid], c = mMap[r.child_mid];
- if (!p || !c) return;
- if (!p.children.find(x => x.id === c.id)) p.children.push(c);
- c._pCount++;
- // 标记入继节点(养父母侧)
- if (r.sub_relation_type === 3) c._isAdoptedIn = true;
- });
- // 配偶关系不再在树中展示(spRels 不处理)
- // ── 3. 找根节点 ──
- let roots = Object.values(mMap).filter(m => !m._pCount);
- if (!roots.length) roots = [Object.values(mMap)[0]];
- // ── 4. 填充缺失世代 ──
- const gVis = new Set();
- function fillGen(node, pg) {
- if (gVis.has(node.id)) return;
- gVis.add(node.id);
- if (node.gen == null || isNaN(node.gen)) {
- if (node.nwg) {
- const parsed = parseLineage(node.nwg);
- if (parsed.length && !isNaN(parsed[0].num)) {
- node.gen = parsed[0].num;
- } else {
- node.gen = pg;
- }
- } else {
- node.gen = pg;
- }
- }
- node.children.forEach(c => fillGen(c, node.gen + 1));
- }
- roots.sort((a, b) => ((a.gen || 999) - (b.gen || 999)));
- roots.forEach(r => fillGen(r, r.gen || 1));
- // ── 5. 后序遍历分配 X 坐标 ──
- const xVis = new Set();
- let xCursor = 0;
- function assignX(node) {
- if (xVis.has(node.id)) return;
- xVis.add(node.id);
- if (!node.children.length) {
- node.x = xCursor + CFG.NODE_SPC / 2;
- xCursor += CFG.NODE_SPC;
- } else {
- node.children.forEach(c => assignX(c));
- const xs = node.children.map(c => c.x);
- node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
- const needed = Math.max(...xs) + CFG.NODE_SPC * 0.5;
- if (needed > xCursor) xCursor = needed;
- }
- }
- roots.forEach(r => { assignX(r); xCursor += CFG.NODE_SPC * 1.5; });
- // ── 6. 依世代计算 Y 坐标(考虑折叠区间) ──
- const allGens = Object.values(mMap).map(m => m.gen).filter(g => g != null && !isNaN(g));
- const minGen = allGens.length ? Math.min(...allGens) : 1;
- const maxGen = allGens.length ? Math.max(...allGens) : 1;
- _minGen = minGen; _maxGen = maxGen;
- const offsetX = CFG.SIDEBAR_W + CFG.LEFT_PAD;
- Object.values(mMap).forEach(m => {
- const g = (!isNaN(m.gen) && m.gen != null) ? m.gen : minGen;
- m.y = computeY(g); // 使用折叠感知的 Y 计算
- m.x = m.x + offsetX;
- });
- // ── 8. 找世系参考点 ──
- let lineageRef = null;
- Object.values(mMap).find(m => {
- if (!m.nwg) return false;
- const parsed = parseLineage(m.nwg);
- if (parsed.length && !isNaN(parsed[0].num)) { lineageRef = { gen: m.gen, lineage: parsed }; return true; }
- return false;
- });
- _lineageRef = lineageRef; _mMap = mMap;
- // ── 9. 创建 SVG ──
- const allX = Object.values(mMap).map(m => m.x);
- const allY = Object.values(mMap).map(m => m.y);
- const maxX = allX.length ? Math.max(...allX) : 800;
- const maxY = allY.length ? Math.max(...allY) : 400;
- const svgW = Math.max(container.offsetWidth || 800, maxX + 150);
- const svgH = Math.max(container.offsetHeight || 600, maxY + 120);
- _zoomBhv = d3.zoom().scaleExtent([0.04, 5]).on('zoom', e => {
- _mainG.attr('transform', e.transform);
- _updateSidebar(e.transform);
- });
- _svgEl = d3.select('#tree-gen-container')
- .append('svg')
- .attr('width', svgW).attr('height', svgH)
- .style('position', 'absolute').style('top', '0').style('left', '0').style('z-index', '2')
- .call(_zoomBhv);
- _mainG = _svgEl.append('g').attr('class', 'main-g');
- // ── 10. 行背景条纹 ──
- const stripeG = _mainG.append('g').attr('class', 'stripes');
- const fullW = Math.max(svgW, maxX + 300);
- {
- let g = minGen;
- let idx = 0;
- while (g <= maxGen) {
- const cr = _collapsedRanges.find(r => r.start === g);
- if (cr) {
- // 折叠区间:特殊背景
- const rowY = computeY(cr.start);
- stripeG.append('rect')
- .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
- .attr('y', rowY - CFG.COLLAPSED_H * 0.45)
- .attr('width', fullW).attr('height', CFG.COLLAPSED_H * 0.9)
- .attr('class', 'collapse-stripe');
- // 上下边界虚线
- [-1, 1].forEach(sign => {
- stripeG.append('line')
- .attr('x1', offsetX - CFG.LEFT_PAD * 0.5)
- .attr('y1', rowY + sign * CFG.COLLAPSED_H * 0.45)
- .attr('x2', fullW)
- .attr('y2', rowY + sign * CFG.COLLAPSED_H * 0.45)
- .attr('stroke', '#fbbf24').attr('stroke-width', 0.8)
- .attr('stroke-dasharray', '4,3');
- });
- g = cr.end + 1; idx++;
- } else {
- const rowY = computeY(g);
- if (idx % 2 === 0) {
- stripeG.append('rect')
- .attr('x', offsetX - CFG.LEFT_PAD * 0.5)
- .attr('y', rowY - CFG.ROW_H * 0.46)
- .attr('width', fullW).attr('height', CFG.ROW_H * 0.92)
- .attr('fill', '#eef2ff').attr('opacity', 0.45);
- }
- if (g > minGen) {
- stripeG.append('line')
- .attr('x1', offsetX - CFG.LEFT_PAD * 0.5).attr('y1', rowY - CFG.ROW_H / 2)
- .attr('x2', fullW).attr('y2', rowY - CFG.ROW_H / 2)
- .attr('stroke', '#e2e8f0').attr('stroke-width', 0.8)
- .attr('stroke-dasharray', '6,5');
- }
- g++; idx++;
- }
- }
- }
- // ── 11. 正常亲子连线(跳过折叠区间中的节点) ──
- const linkG = _mainG.append('g').attr('class', 'links');
- Object.values(mMap).forEach(node => {
- if (isCollapsedGen(node.gen)) return; // 折叠区内的节点不画线
- const visChildren = node.children.filter(c => !isCollapsedGen(c.gen));
- if (!visChildren.length) return;
- const px = node.x, py = node.y;
- const midY = py + CFG.ROW_H * 0.40;
- const xs = visChildren.map(c => c.x);
- const minCX = Math.min(...xs), maxCX = Math.max(...xs);
- linkG.append('line').attr('x1', px).attr('y1', py + CFG.NODE_R + 2).attr('x2', px).attr('y2', midY).attr('class', 'link-parent');
- if (xs.length > 1) {
- linkG.append('line').attr('x1', minCX).attr('y1', midY).attr('x2', maxCX).attr('y2', midY).attr('class', 'link-parent');
- }
- visChildren.forEach(child => {
- linkG.append('line').attr('x1', child.x).attr('y1', midY).attr('x2', child.x).attr('y2', child.y - CFG.NODE_R - 2).attr('class', 'link-parent');
- });
- });
- // ── 11b. 折叠穿透虚线(Collapse connectors) ──
- const colConnG = _mainG.append('g').attr('class', 'collapse-conns');
- _collapsedRanges.forEach(cr => {
- Object.values(mMap).forEach(entryNode => {
- if (isCollapsedGen(entryNode.gen)) return;
- if (entryNode.gen >= cr.start) return;
- const hasChildInRange = entryNode.children.some(c => c.gen >= cr.start && c.gen <= cr.end);
- if (!hasChildInRange) return;
- const exits = findExitNodes(entryNode, cr);
- if (!exits.length) return;
- const px = entryNode.x, py = entryNode.y;
- const exitYs = exits.map(e => e.y);
- const exitY = Math.min(...exitYs); // 最近的出口行 Y
- const midY = py + (exitY - py) * 0.50; // 虚线中点
- const exitXs = exits.map(e => e.x);
- const allXs = [px, ...exitXs];
- const minBX = Math.min(...allXs), maxBX = Math.max(...allXs);
- // 父节点向下
- colConnG.append('line')
- .attr('x1', px).attr('y1', py + CFG.NODE_R + 2)
- .attr('x2', px).attr('y2', midY)
- .attr('class', 'link-collapse');
- // 横向汇聚线
- if (exits.length > 1 || Math.abs(px - exits[0].x) > 2) {
- colConnG.append('line')
- .attr('x1', minBX).attr('y1', midY)
- .attr('x2', maxBX).attr('y2', midY)
- .attr('class', 'link-collapse');
- }
- // 到各出口节点
- exits.forEach(exit => {
- colConnG.append('line')
- .attr('x1', exit.x).attr('y1', midY)
- .attr('x2', exit.x).attr('y2', exit.y - CFG.NODE_R - 2)
- .attr('class', 'link-collapse');
- });
- // 角标:× N代
- const count = cr.end - cr.start + 1;
- const bdgX = (minBX + maxBX) / 2;
- const bdgY = midY;
- const label = `↑收紧 ${count}代`;
- colConnG.append('rect')
- .attr('x', bdgX - 30).attr('y', bdgY - 11)
- .attr('width', 60).attr('height', 22)
- .attr('rx', 11).attr('fill', '#fffbeb')
- .attr('stroke', '#fbbf24').attr('stroke-width', 1.2);
- colConnG.append('text')
- .attr('x', bdgX).attr('y', bdgY + 1)
- .attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
- .attr('font-size', '10px').attr('font-weight', '600')
- .attr('fill', '#d97706').attr('font-family', '"Microsoft YaHei", sans-serif')
- .text(label);
- });
- });
- // ── 12. 配偶连线已移除 ──
- // ── 13. 节点(跳过折叠区内) ──
- const nodeG = _mainG.append('g').attr('class', 'nodes');
- Object.values(mMap).forEach(m => {
- if (isCollapsedGen(m.gen)) return; // 跳过折叠区内节点
- const sexCls = m.sex === 1 ? 'node-male' : m.sex === 2 ? 'node-female' : 'node-unknown';
- const adoptedCls = m._isAdoptedIn ? ' node-adopted-in' : '';
- const ng = nodeG.append('g')
- .attr('class', `gen-node ${sexCls}${adoptedCls}`)
- .attr('data-mid', m.id)
- .attr('transform', `translate(${m.x},${m.y})`)
- .style('cursor', 'pointer')
- .on('contextmenu', event => {
- event.preventDefault();
- _selMid = m.id;
- const cr = container.getBoundingClientRect();
- _ctxMenu.style.display = 'block';
- _ctxMenu.style.left = (event.clientX - cr.left) + 'px';
- _ctxMenu.style.top = (event.clientY - cr.top) + 'px';
- })
- .on('dblclick', () => { if (m.id) window.location.href = `/manager/member_detail/${m.id}`; });
- const R = CFG.NODE_R;
- if (m.sex === 1) {
- ng.append('rect').attr('x', -R).attr('y', -R).attr('width', R*2).attr('height', R*2).attr('rx', 4).attr('ry', 4);
- } else {
- ng.append('circle').attr('r', R);
- }
- // 入继标签(节点正上方)
- if (m._isAdoptedIn) {
- ng.append('text').attr('class', 'node-adopt-label')
- .attr('x', 0).attr('y', -R - 3).attr('dy', '-0.2em')
- .attr('text-anchor', 'middle').text('入继');
- }
- const disp = m.sname || m.name;
- const short = disp.length > CFG.MAX_NAME ? disp.slice(0, CFG.MAX_NAME) + '…' : disp;
- const txt = ng.append('text').attr('class', 'node-name-text')
- .attr('x', 0).attr('y', R + 16).attr('dy', '0.15em').attr('text-anchor', 'middle').text(short);
- if (disp.length > CFG.MAX_NAME || (m.name && m.sname && m.name !== m.sname)) {
- const tip = m.name + (m.sname && m.name !== m.sname ? ` (${m.sname})` : '');
- txt.append('title').text(tip);
- ng.append('title').text(tip);
- }
- });
- // ── 14. 侧边栏 ──
- _buildSidebar(minGen, maxGen, lineageRef);
- _updateSidebar(d3.zoomIdentity);
- }
- // ═══════════════════════════════════════════════════════════
- // 侧边栏构建
- // ═══════════════════════════════════════════════════════════
- function _buildSidebar(minGen, maxGen, lineageRef) {
- const sidebar = document.getElementById('gen-sidebar');
- let g = minGen;
- while (g <= maxGen) {
- // 检查是否是折叠区间的起点
- const cr = _collapsedRanges.find(r => r.start === g);
- if (cr) {
- // 折叠行(点击展开)
- const count = cr.end - cr.start + 1;
- const div = document.createElement('div');
- div.className = 'gen-collapsed-row';
- div.dataset.start = cr.start;
- div.dataset.end = cr.end;
- div.innerHTML =
- `<span class="cr-title">⇕ 第${numToCN(cr.start)}~${numToCN(cr.end)}世</span>` +
- `<span class="cr-hint">(${count}代已收紧,点击展开)</span>`;
- div.onclick = () => expandRange(cr.start, cr.end);
- sidebar.appendChild(div);
- g = cr.end + 1;
- continue;
- }
- // 正常世代标签行
- const div = document.createElement('div');
- div.className = 'gen-label-row';
- div.dataset.gen = g;
- const numSpan = document.createElement('span');
- numSpan.className = 'gen-label-num';
- numSpan.textContent = `第${numToCN(g)}世`;
- div.appendChild(numSpan);
- if (lineageRef) {
- lineageLbl(lineageRef.lineage, lineageRef.gen, g).forEach(lbl => {
- const sp = document.createElement('span');
- sp.className = 'gen-label-lineage';
- sp.textContent = lbl;
- div.appendChild(sp);
- });
- }
- sidebar.appendChild(div);
- // 手柄:在当前代和下一代之间(如果下一代存在且未到 maxGen)
- if (g < maxGen) {
- const handle = document.createElement('div');
- handle.className = 'gen-collapse-handle';
- handle.dataset.between = g;
- handle.title = `点击标记折叠边界(第${numToCN(g)}~${numToCN(g+1)}世之间)`;
- handle.innerHTML = '<div class="h-line"><div class="h-btn">─</div></div>';
- handle.addEventListener('click', function(e) {
- e.stopPropagation();
- handleCollapseClick(parseInt(this.dataset.between), this);
- });
- sidebar.appendChild(handle);
- }
- g++;
- }
- }
- // ═══════════════════════════════════════════════════════════
- // 侧边栏位置同步
- // ═══════════════════════════════════════════════════════════
- function _updateSidebar(transform) {
- const k = transform.k, ty = transform.y;
- // 世代标签行
- document.querySelectorAll('#gen-sidebar .gen-label-row').forEach(el => {
- const g = parseInt(el.dataset.gen);
- el.style.top = (computeY(g) * k + ty) + 'px';
- });
- // 折叠手柄(在 gen g 和 g+1 之间的中点)
- document.querySelectorAll('#gen-sidebar .gen-collapse-handle').forEach(el => {
- const bg = parseInt(el.dataset.between);
- const y = (computeY(bg) + computeY(bg + 1)) / 2;
- el.style.top = (y * k + ty) + 'px';
- });
- // 折叠区间行(居中于折叠区间虚拟行)
- document.querySelectorAll('#gen-sidebar .gen-collapsed-row').forEach(el => {
- const start = parseInt(el.dataset.start);
- // 折叠区间起始的内容 Y + 半个虚拟行高
- const y = computeY(start) + CFG.COLLAPSED_H / 2;
- el.style.top = (y * k + ty) + 'px';
- });
- }
- // ═══════════════════════════════════════════════════════════
- // 缩放控件
- // ═══════════════════════════════════════════════════════════
- function zoomIn() { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 1.35); }
- function zoomOut() { if (_svgEl) _svgEl.transition().duration(280).call(_zoomBhv.scaleBy, 0.75); }
- function zoomReset() { if (_svgEl) _svgEl.transition().duration(300).call(_zoomBhv.transform, d3.zoomIdentity); }
- // ═══════════════════════════════════════════════════════════
- // 右键菜单
- // ═══════════════════════════════════════════════════════════
- function menuAction(type) {
- switch(type) {
- case 'detail': if (_selMid) window.location.href = `/manager/member_detail/${_selMid}`; break;
- case 'edit': if (_selMid) window.location.href = `/manager/edit_member/${_selMid}`; break;
- case 'add': window.location.href = '/manager/add_member'; break;
- }
- }
- // ═══════════════════════════════════════════════════════════
- // 搜索
- // ═══════════════════════════════════════════════════════════
- function searchMember() {
- const term = document.getElementById('genSearch').value.trim().toLowerCase();
- if (!term) return;
- if (!_rawData || !_rawData.members) { alert('数据未加载完成,请稍候再试'); return; }
- const matches = _rawData.members.filter(m =>
- (m.name || '').toLowerCase().includes(term) ||
- (m.simplified_name || '').toLowerCase().includes(term)
- );
- if (!matches.length) { alert('未找到匹配成员'); return; }
- const target = matches[0];
- const node = _mMap && _mMap[target.id];
- if (!node || !_svgEl) return;
- const cont = document.getElementById('tree-gen-container');
- const scale = 1.6;
- _svgEl.transition().duration(800).call(
- _zoomBhv.transform,
- d3.zoomIdentity.translate(cont.clientWidth/2 - node.x*scale, cont.clientHeight/2 - node.y*scale).scale(scale)
- );
- _mainG.selectAll('.gen-node').filter(function() {
- return d3.select(this).attr('data-mid') == target.id;
- }).classed('node-highlight', true);
- setTimeout(() => { _mainG.selectAll('.gen-node').classed('node-highlight', false); }, 2000);
- }
- document.getElementById('genSearch').addEventListener('keypress', e => {
- if (e.key === 'Enter') searchMember();
- });
- </script>
- {% endblock %}
|